2026-04-21 14:56:16 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Profile Management Endpoints for Mitai Jinkendo
|
|
|
|
|
|
|
|
|
|
|
|
Handles profile CRUD operations for both admin and current user.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
|
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
|
|
from auth import require_auth
|
2026-05-05 16:40:49 +02:00
|
|
|
|
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
|
2026-05-05 22:05:10 +02:00
|
|
|
|
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
2026-04-21 14:56:16 +02:00
|
|
|
|
from models import ProfileCreate, ProfileUpdate
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api", tags=["profiles"])
|
|
|
|
|
|
|
2026-05-05 21:05:52 +02:00
|
|
|
|
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
# ── Helper ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
|
|
|
|
|
|
"""Get profile_id - from header for legacy endpoints."""
|
|
|
|
|
|
if x_profile_id:
|
|
|
|
|
|
return x_profile_id
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if row: return row['id']
|
|
|
|
|
|
raise HTTPException(400, "Kein Profil gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-22 15:46:40 +02:00
|
|
|
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/profiles/me")
|
2026-05-05 21:42:56 +02:00
|
|
|
|
def get_current_profile(
|
|
|
|
|
|
session=Depends(require_auth),
|
|
|
|
|
|
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Profil inkl. Vereinsmitgliedschaften; effective_club_id = aufgelöster Request-Kontext (Header vor Profilfeld)."""
|
|
|
|
|
|
profile_id = session["profile_id"]
|
2026-04-22 15:46:40 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Profil nicht gefunden")
|
2026-05-05 16:12:17 +02:00
|
|
|
|
data = r2d(row)
|
|
|
|
|
|
data.pop("pin_hash", None)
|
2026-05-05 21:42:56 +02:00
|
|
|
|
clubs = memberships_with_roles(cur, profile_id)
|
|
|
|
|
|
data["clubs"] = clubs
|
|
|
|
|
|
ac_raw = data.get("active_club_id")
|
|
|
|
|
|
stored_ac = int(ac_raw) if ac_raw is not None and ac_raw != "" else None
|
|
|
|
|
|
tenant = resolve_tenant_context(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
profile_id=int(profile_id),
|
|
|
|
|
|
global_role=session.get("role") or "",
|
|
|
|
|
|
header_raw=x_active_club_id,
|
|
|
|
|
|
memberships=clubs,
|
|
|
|
|
|
stored_active_club_id=stored_ac,
|
|
|
|
|
|
invalid_header_policy="ignore",
|
|
|
|
|
|
)
|
|
|
|
|
|
data["effective_club_id"] = tenant.effective_club_id
|
2026-05-05 16:12:17 +02:00
|
|
|
|
return data
|
2026-04-22 15:46:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
|
# ── Admin Profile Management ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/profiles")
|
|
|
|
|
|
def list_profiles(session=Depends(require_auth)):
|
2026-05-05 16:40:49 +02:00
|
|
|
|
"""Liste aller Profile (nur Plattform-Admin)."""
|
|
|
|
|
|
role = (session.get("role") or "").lower()
|
|
|
|
|
|
if not is_platform_admin(role):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen")
|
2026-04-21 14:56:16 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM profiles ORDER BY created")
|
|
|
|
|
|
rows = cur.fetchall()
|
2026-05-05 16:40:49 +02:00
|
|
|
|
out = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
d = r2d(r)
|
|
|
|
|
|
d.pop("pin_hash", None)
|
|
|
|
|
|
out.append(d)
|
|
|
|
|
|
return out
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/profiles")
|
|
|
|
|
|
def create_profile(p: ProfileCreate, session=Depends(require_auth)):
|
|
|
|
|
|
"""Create new profile (admin)."""
|
|
|
|
|
|
pid = str(uuid.uuid4())
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("""INSERT INTO profiles (id,name,avatar_color,sex,dob,height,goal_weight,goal_bf_pct,created,updated)
|
|
|
|
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)""",
|
|
|
|
|
|
(pid,p.name,p.avatar_color,p.sex,p.dob,p.height,p.goal_weight,p.goal_bf_pct))
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
|
|
|
|
return r2d(cur.fetchone())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def profile_document(pid: str) -> dict:
|
|
|
|
|
|
"""Profil ohne PIN — für Routen und interne Aufrufe (z. B. /auth/me)."""
|
2026-04-21 14:56:16 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
|
|
|
|
row = cur.fetchone()
|
2026-05-05 22:05:10 +02:00
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Profil nicht gefunden")
|
2026-05-05 21:05:52 +02:00
|
|
|
|
d = r2d(row)
|
|
|
|
|
|
d.pop("pin_hash", None)
|
|
|
|
|
|
return d
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-05 22:05:10 +02:00
|
|
|
|
@router.get("/profiles/{pid}")
|
|
|
|
|
|
def get_profile(pid: str, _session=Depends(require_auth)):
|
|
|
|
|
|
"""Get profile by ID."""
|
|
|
|
|
|
return profile_document(pid)
|
|
|
|
|
|
def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> dict:
|
|
|
|
|
|
"""Gemeinsame PUT-Logik für /profiles/{id} und Legacy /profile."""
|
|
|
|
|
|
sess_pid = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
|
|
|
|
|
if str(sess_pid) != str(pid) and role not in ("admin", "superadmin"):
|
|
|
|
|
|
raise HTTPException(403, "Keine Berechtigung für dieses Profil")
|
2026-04-29 11:19:05 +02:00
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(404, "Profil nicht gefunden")
|
|
|
|
|
|
rowd = r2d(row)
|
|
|
|
|
|
cur_email_norm = (rowd.get("email") or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
patch = p.model_dump(exclude_unset=True)
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
|
2026-05-05 21:05:52 +02:00
|
|
|
|
if "role" in patch or "tier" in patch:
|
|
|
|
|
|
if not is_platform_admin(role):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
detail="Nur Portal-Admins dürfen Rolle oder Tier ändern",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if "role" in patch:
|
|
|
|
|
|
new_role = (patch["role"] or "").strip().lower()
|
|
|
|
|
|
if new_role not in _ALLOWED_PORTAL_ROLES:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Ungültige Portal-Rolle. Erlaubt: {', '.join(sorted(_ALLOWED_PORTAL_ROLES))}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if new_role == "superadmin" and role != "superadmin":
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
detail="Nur Super-Admins dürfen die Rolle Super-Admin vergeben",
|
|
|
|
|
|
)
|
|
|
|
|
|
old_r = (rowd.get("role") or "user").strip().lower()
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT COUNT(*)::int AS c FROM profiles
|
|
|
|
|
|
WHERE lower(trim(role)) IN ('admin','superadmin')
|
|
|
|
|
|
"""
|
|
|
|
|
|
)
|
|
|
|
|
|
admin_cnt = int(cur.fetchone()["c"])
|
|
|
|
|
|
if old_r in ("admin", "superadmin") and new_role not in ("admin", "superadmin"):
|
|
|
|
|
|
if admin_cnt <= 1:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Der letzte Portal-Administrator kann nicht zurückgestuft werden",
|
|
|
|
|
|
)
|
|
|
|
|
|
data["role"] = new_role
|
|
|
|
|
|
del patch["role"]
|
|
|
|
|
|
|
|
|
|
|
|
if "tier" in patch:
|
|
|
|
|
|
tv = patch["tier"]
|
|
|
|
|
|
if tv is None:
|
|
|
|
|
|
data["tier"] = "free"
|
|
|
|
|
|
else:
|
|
|
|
|
|
ts = str(tv).strip()
|
|
|
|
|
|
data["tier"] = (ts or "free")[:50]
|
|
|
|
|
|
del patch["tier"]
|
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
|
if "email" in patch:
|
|
|
|
|
|
ev = patch["email"]
|
|
|
|
|
|
if ev is None or (isinstance(ev, str) and ev.strip() == ""):
|
|
|
|
|
|
if rowd.get("email") is not None:
|
|
|
|
|
|
data["email"] = None
|
|
|
|
|
|
data["email_verified"] = False
|
|
|
|
|
|
data["verification_token"] = None
|
|
|
|
|
|
data["verification_expires"] = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
email_norm = ev.strip().lower()
|
|
|
|
|
|
if "@" not in email_norm or len(email_norm) < 5:
|
|
|
|
|
|
raise HTTPException(400, "Ungültige E-Mail-Adresse")
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT id FROM profiles
|
|
|
|
|
|
WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(email_norm, pid),
|
|
|
|
|
|
)
|
|
|
|
|
|
if cur.fetchone():
|
|
|
|
|
|
raise HTTPException(409, "E-Mail wird bereits verwendet")
|
|
|
|
|
|
data["email"] = email_norm
|
|
|
|
|
|
if email_norm != cur_email_norm:
|
|
|
|
|
|
data["email_verified"] = False
|
|
|
|
|
|
data["verification_token"] = None
|
|
|
|
|
|
data["verification_expires"] = None
|
|
|
|
|
|
|
2026-05-05 16:12:17 +02:00
|
|
|
|
if "active_club_id" in patch:
|
|
|
|
|
|
ac = patch["active_club_id"]
|
|
|
|
|
|
if ac is None:
|
|
|
|
|
|
data["active_club_id"] = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
try:
|
|
|
|
|
|
cid = int(ac)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise HTTPException(400, "active_club_id ungültig")
|
|
|
|
|
|
assert_club_member(cur, int(pid), cid)
|
|
|
|
|
|
data["active_club_id"] = cid
|
|
|
|
|
|
|
2026-04-21 14:56:16 +02:00
|
|
|
|
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
|
|
|
|
|
for k, v in patch.items():
|
|
|
|
|
|
if k == "email":
|
|
|
|
|
|
continue
|
2026-05-05 16:12:17 +02:00
|
|
|
|
if k == "active_club_id":
|
|
|
|
|
|
continue
|
2026-04-21 14:56:16 +02:00
|
|
|
|
if v is None and k in nullable_keys:
|
|
|
|
|
|
data[k] = None
|
|
|
|
|
|
elif v is not None:
|
|
|
|
|
|
data[k] = v
|
|
|
|
|
|
|
|
|
|
|
|
if not data:
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return profile_document(pid)
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
2026-04-29 11:19:05 +02:00
|
|
|
|
data["updated_at"] = datetime.now()
|
2026-04-21 14:56:16 +02:00
|
|
|
|
cols = ", ".join(f"{k}=%s" for k in data)
|
|
|
|
|
|
vals = list(data.values()) + [pid]
|
|
|
|
|
|
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return profile_document(pid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/profiles/{pid}")
|
|
|
|
|
|
def update_profile(pid: str, p: ProfileUpdate, tenant: TenantContext = Depends(get_tenant_context)):
|
|
|
|
|
|
"""Update profile — nur eigenes Profil oder Admin; TenantContext validiert X-Active-Club-Id."""
|
|
|
|
|
|
return _run_profile_update(pid, p, tenant)
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/profiles/{pid}")
|
|
|
|
|
|
def delete_profile(pid: str, session=Depends(require_auth)):
|
|
|
|
|
|
"""Delete profile (admin)."""
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute("SELECT COUNT(*) as count FROM profiles")
|
|
|
|
|
|
count = cur.fetchone()['count']
|
|
|
|
|
|
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
|
|
|
|
|
|
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
|
|
|
|
|
|
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
|
|
|
|
|
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
|
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Current User Profile ──────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/profile")
|
|
|
|
|
|
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
|
|
|
|
|
|
"""Legacy endpoint – returns active profile."""
|
|
|
|
|
|
pid = get_pid(x_profile_id)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return profile_document(pid)
|
2026-04-21 14:56:16 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/profile")
|
2026-05-05 22:05:10 +02:00
|
|
|
|
def update_active_profile(
|
|
|
|
|
|
p: ProfileUpdate,
|
|
|
|
|
|
x_profile_id: Optional[str] = Header(default=None),
|
|
|
|
|
|
tenant: TenantContext = Depends(get_tenant_context),
|
|
|
|
|
|
):
|
2026-04-21 14:56:16 +02:00
|
|
|
|
"""Update current user's profile."""
|
|
|
|
|
|
pid = get_pid(x_profile_id)
|
2026-05-05 22:05:10 +02:00
|
|
|
|
return _run_profile_update(pid, p, tenant)
|