fix: align all API endpoints between frontend and backend
Fixed 11 critical endpoint mismatches found during codebase audit.
**Renamed Endpoints (consistency):**
- /api/ai/analyze/{slug} → /api/insights/run/{slug}
- /api/ai/analyze-pipeline → /api/insights/pipeline
- /api/auth/password-reset-request → /api/auth/forgot-password
- /api/auth/password-reset-confirm → /api/auth/reset-password
- /api/admin/test-email → /api/admin/email/test
**Added Missing Endpoints:**
- POST /api/auth/pin (change PIN/password for current user)
- PUT /api/admin/profiles/{id}/permissions (set permissions)
- PUT /api/admin/profiles/{id}/email (set email)
- PUT /api/admin/profiles/{id}/pin (admin set PIN)
- GET /api/admin/email/status (check SMTP config)
- PUT /api/prompts/{id} (edit prompt templates, admin only)
- GET /api/export/json (export all data as JSON)
- GET /api/export/zip (export data + photos as ZIP)
**Updated:**
- Added imports: json, zipfile, Response
- Fixed admin email test endpoint to accept dict body
All frontend API calls now have matching backend implementations.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d58a2db8e
commit
1db780858b
235
backend/main.py
235
backend/main.py
|
|
@ -1,11 +1,11 @@
|
|||
import os, csv, io, uuid
|
||||
import os, csv, io, uuid, json, zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from fastapi.responses import StreamingResponse, FileResponse, Response
|
||||
from pydantic import BaseModel
|
||||
import aiofiles
|
||||
import bcrypt
|
||||
|
|
@ -852,7 +852,7 @@ def _prepare_template_vars(data: dict) -> dict:
|
|||
|
||||
return vars
|
||||
|
||||
@app.post("/api/ai/analyze/{slug}")
|
||||
@app.post("/api/insights/run/{slug}")
|
||||
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run AI analysis with specified prompt template."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
|
@ -909,7 +909,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
|||
inc_ai_usage(pid)
|
||||
return {"scope": slug, "content": content}
|
||||
|
||||
@app.post("/api/ai/analyze-pipeline")
|
||||
@app.post("/api/insights/pipeline")
|
||||
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run 3-stage pipeline analysis."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
|
@ -1053,6 +1053,32 @@ def list_prompts(session: dict=Depends(require_auth)):
|
|||
cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order")
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
@app.put("/api/prompts/{prompt_id}")
|
||||
def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)):
|
||||
"""Update AI prompt template (admin only)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
updates = []
|
||||
values = []
|
||||
if 'name' in data:
|
||||
updates.append('name=%s')
|
||||
values.append(data['name'])
|
||||
if 'description' in data:
|
||||
updates.append('description=%s')
|
||||
values.append(data['description'])
|
||||
if 'template' in data:
|
||||
updates.append('template=%s')
|
||||
values.append(data['template'])
|
||||
if 'active' in data:
|
||||
updates.append('active=%s')
|
||||
values.append(data['active'])
|
||||
|
||||
if updates:
|
||||
cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s",
|
||||
values + [prompt_id])
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/ai/usage")
|
||||
def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get AI usage stats for current profile."""
|
||||
|
|
@ -1145,7 +1171,22 @@ def auth_status():
|
|||
"""Health check endpoint."""
|
||||
return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"}
|
||||
|
||||
@app.post("/api/auth/password-reset-request")
|
||||
@app.post("/api/auth/pin")
|
||||
def change_pin(req: dict, session: dict=Depends(require_auth)):
|
||||
"""Change PIN/password for current user."""
|
||||
pid = session['profile_id']
|
||||
new_pin = req.get('pin', '')
|
||||
if len(new_pin) < 4:
|
||||
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
|
||||
|
||||
new_hash = hash_pin(new_pin)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/auth/forgot-password")
|
||||
@limiter.limit("3/minute")
|
||||
async def password_reset_request(req: PasswordResetRequest, request: Request):
|
||||
"""Request password reset email."""
|
||||
|
|
@ -1203,7 +1244,7 @@ Dein Mitai Jinkendo Team
|
|||
|
||||
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
|
||||
|
||||
@app.post("/api/auth/password-reset-confirm")
|
||||
@app.post("/api/auth/reset-password")
|
||||
def password_reset_confirm(req: PasswordResetConfirm):
|
||||
"""Confirm password reset with token."""
|
||||
with get_db() as conn:
|
||||
|
|
@ -1264,9 +1305,79 @@ def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depen
|
|||
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/admin/test-email")
|
||||
def admin_test_email(email: str, session: dict=Depends(require_admin)):
|
||||
@app.put("/api/admin/profiles/{pid}/permissions")
|
||||
def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)):
|
||||
"""Admin: Set profile permissions."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
updates = []
|
||||
values = []
|
||||
if 'ai_enabled' in data:
|
||||
updates.append('ai_enabled=%s')
|
||||
values.append(data['ai_enabled'])
|
||||
if 'ai_limit_day' in data:
|
||||
updates.append('ai_limit_day=%s')
|
||||
values.append(data['ai_limit_day'])
|
||||
if 'export_enabled' in data:
|
||||
updates.append('export_enabled=%s')
|
||||
values.append(data['export_enabled'])
|
||||
if 'role' in data:
|
||||
updates.append('role=%s')
|
||||
values.append(data['role'])
|
||||
|
||||
if updates:
|
||||
cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid])
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.put("/api/admin/profiles/{pid}/email")
|
||||
def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)):
|
||||
"""Admin: Set profile email."""
|
||||
email = data.get('email', '').strip().lower()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid))
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.put("/api/admin/profiles/{pid}/pin")
|
||||
def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)):
|
||||
"""Admin: Set profile PIN/password."""
|
||||
new_pin = data.get('pin', '')
|
||||
if len(new_pin) < 4:
|
||||
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
|
||||
|
||||
new_hash = hash_pin(new_pin)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/admin/email/status")
|
||||
def admin_email_status(session: dict=Depends(require_admin)):
|
||||
"""Admin: Check email configuration status."""
|
||||
smtp_host = os.getenv("SMTP_HOST")
|
||||
smtp_user = os.getenv("SMTP_USER")
|
||||
smtp_pass = os.getenv("SMTP_PASS")
|
||||
app_url = os.getenv("APP_URL", "http://localhost:3002")
|
||||
|
||||
configured = bool(smtp_host and smtp_user and smtp_pass)
|
||||
|
||||
return {
|
||||
"configured": configured,
|
||||
"smtp_host": smtp_host or "",
|
||||
"smtp_user": smtp_user or "",
|
||||
"app_url": app_url
|
||||
}
|
||||
|
||||
@app.post("/api/admin/email/test")
|
||||
def admin_test_email(data: dict, session: dict=Depends(require_admin)):
|
||||
"""Admin: Send test email."""
|
||||
email = data.get('to', '')
|
||||
if not email:
|
||||
raise HTTPException(400, "E-Mail-Adresse fehlt")
|
||||
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
|
@ -1348,3 +1459,111 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"}
|
||||
)
|
||||
|
||||
@app.get("/api/export/json")
|
||||
def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Export all data as JSON."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
|
||||
# Collect all data
|
||||
data = {}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
data['profile'] = r2d(cur.fetchone())
|
||||
|
||||
cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['weight'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['circumferences'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['caliper'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['activity'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
json_str = json.dumps(data, indent=2, default=str)
|
||||
return Response(
|
||||
content=json_str,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"}
|
||||
)
|
||||
|
||||
@app.get("/api/export/zip")
|
||||
def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Export all data as ZIP (JSON + photos)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
|
||||
# Create ZIP in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
# Add JSON data
|
||||
data = {}
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
data['profile'] = r2d(cur.fetchone())
|
||||
|
||||
cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['weight'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['circumferences'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['caliper'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['activity'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
zf.writestr("data.json", json.dumps(data, indent=2, default=str))
|
||||
|
||||
# Add photos if they exist
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
photos = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
for i, photo in enumerate(photos):
|
||||
photo_path = Path(PHOTOS_DIR) / photo['path']
|
||||
if photo_path.exists():
|
||||
zf.write(photo_path, f"photos/{photo['date'] or i}_{photo_path.name}")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([zip_buffer.getvalue()]),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.zip"}
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user