banyaro/backend/routes/profile.py
rene d0a76e1b54 Ban Yaro Blues — Hymne in der WELT-Welt
Eigener Song (KI-Demo via Suno) als Marken-Hymne. Dezente Player-Karte unter
dem Tageszitat; preload=none → 6 MB MP3 lädt erst bei Play, der SW cacht sie
danach für offline. Der Banner ist einmalige Einladung und verschwindet nach
erstem Hören (durchgehört oder >30s + Pause); danach dezenter runder Play-Button
unten links als Gegenspieler zum FAB, nur in WELT. Audio-Element zentral in
index.html → übersteht Welt-Wechsel & Re-Renders.

„Gehört" wird hybrid gemerkt: localStorage (sofort/offline) + DB-Flag
anthem_heard am User (neue Spalte, über /auth/me, gesetzt via
POST /api/profile/anthem-heard) — geräte- und deploy-übergreifend, damit der
Banner nicht erneut nervt.
2026-06-14 21:33:23 +02:00

372 lines
15 KiB
Python

"""BAN YARO — User-Profil Routes"""
import io
import os
import re
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from auth import get_current_user
from database import db
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
VALID_ERFAHRUNG = {"einsteiger", "erfahren", "trainer", "zuechter"}
VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel):
real_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=300)
wohnort: Optional[str] = Field(None, max_length=60)
erfahrung: Optional[str] = Field(None, max_length=30)
social_link: Optional[str] = Field(None, max_length=120)
profil_sichtbarkeit: Optional[str] = Field(None, max_length=30)
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = Field(None, max_length=20)
billing_address: Optional[str] = Field(None, max_length=500)
geburtstag: Optional[str] = Field(None, max_length=10)
def _load_user(user_id: int) -> dict:
with db() as conn:
row = conn.execute(
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at, billing_address,
geburtstag
FROM users WHERE id=?""",
(user_id,)
).fetchone()
if not row:
raise HTTPException(404, "User nicht gefunden.")
data = dict(row)
data["is_premium"] = bool(data["is_premium"])
return data
@router.post("/anthem-heard", status_code=204)
async def mark_anthem_heard(user=Depends(get_current_user)):
"""Merkt server-seitig, dass die Hymne gehört wurde — geräteübergreifend und
übersteht Cache-Clear bzw. die iOS-Trennung von PWA- und Safari-localStorage,
damit der Banner nach einem Deploy nicht erneut auftaucht."""
with db() as conn:
conn.execute("UPDATE users SET anthem_heard=1 WHERE id=?", (user["id"],))
return None
@router.patch("")
async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
fields = data.model_dump(exclude_none=True)
# Validierungen
if "erfahrung" in fields and fields["erfahrung"] not in VALID_ERFAHRUNG:
raise HTTPException(400, f"erfahrung muss eines von {sorted(VALID_ERFAHRUNG)} sein.")
if "profil_sichtbarkeit" in fields and fields["profil_sichtbarkeit"] not in VALID_SICHTBARKEIT:
raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.")
if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"):
raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.")
# Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt.
if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).")
if not fields:
return _load_user(user["id"])
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [user["id"]]
with db() as conn:
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?", values
)
return _load_user(user["id"])
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
user=Depends(get_current_user),
):
# HEIC-Support registrieren falls vorhanden
try:
import pillow_heif
pillow_heif.register_heif_opener()
except ImportError:
pass
from PIL import Image, ImageOps
content = await file.read()
try:
img = Image.open(io.BytesIO(content))
img = ImageOps.exif_transpose(img) # EXIF-Orientierung anwenden
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
content = buf.getvalue()
except Exception:
pass # Fallback: Originaldaten speichern
filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}.jpg"
path = os.path.join(MEDIA_DIR, "avatars", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(content)
avatar_url = f"/media/avatars/{filename}"
with db() as conn:
conn.execute(
"UPDATE users SET avatar_url=? WHERE id=?", (avatar_url, user["id"])
)
return {"avatar_url": avatar_url}
# ----------------------------------------------------------
# GET /profile/world-config — Welten-Chip-Konfiguration laden
# PUT /profile/world-config — Welten-Chip-Konfiguration speichern
# ----------------------------------------------------------
import json as _json
@router.get('/world-config')
async def get_world_config(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT world_config FROM users WHERE id=?", (user['id'],)).fetchone()
cfg = row['world_config'] if row and row['world_config'] else None
return {"config": _json.loads(cfg) if cfg else None}
class WorldConfigIn(BaseModel):
config: dict
@router.put('/world-config')
async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
with db() as conn:
conn.execute("UPDATE users SET world_config=? WHERE id=?",
(_json.dumps(body.config), user['id']))
return {"status": "ok"}
# ----------------------------------------------------------
# DELETE /profile/account — Konto unwiderruflich löschen
# ----------------------------------------------------------
# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller),
# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde
# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen.
_ACTOR_COLUMNS = {
("wiki_foto_submissions", "reviewed_by"),
("osm_poi_edits", "mod_id"),
("partner_codes", "created_by"),
("outreach_log", "sent_by"),
("upgrade_requests", "fulfilled_by"),
}
@router.delete('/account')
async def delete_account(user=Depends(get_current_user)):
"""Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4).
FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die
auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch
geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions-
Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die
Reihenfolge irrelevant — geprüft wird erst beim Commit.
"""
uid = user['id']
with db() as conn:
# FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal.
conn.execute("PRAGMA defer_foreign_keys=ON")
tables = [r['name'] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()]
# Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde
# (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst.
handled_fk_cols: set[tuple] = set()
# --- 1) Formale FKs auf users(id) ---
for tbl in tables:
try:
fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall()
except Exception:
continue
for fk in fks:
if fk['table'] != 'users':
continue
col = fk['from']
handled_fk_cols.add((tbl, col))
on_delete = (fk['on_delete'] or '').upper()
if on_delete == 'CASCADE':
continue # wird durch den finalen users-DELETE mitgelöscht
if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS:
conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,))
else:
# NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen.
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) ---
# Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt
# die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach.
for tbl in tables:
try:
cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()}
except Exception:
continue
for col in ('user_id', 'owner_id'):
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE
# entfernt nur die DB-Zeilen, nicht die Dateien.
import os as _os
from media_utils import delete_media_files
_note_media_urls = [r["url"] for r in conn.execute(
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
(uid,)
).fetchall()]
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls)
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}
# ----------------------------------------------------------
# GET /profile/export — DSGVO Datenexport (Art. 20)
# ----------------------------------------------------------
@router.get('/export')
async def export_user_data(user=Depends(get_current_user)):
"""Gibt alle personenbezogenen Daten des Users als JSON zurück (Art. 20 DSGVO)."""
import json as _json
from datetime import datetime as _dt
from fastapi.responses import Response as _Response
def _q(conn, sql, params=()):
"""Sicheres Query — gibt leere Liste zurück wenn Tabelle/Spalte fehlt."""
try:
return [dict(r) for r in conn.execute(sql, params).fetchall()]
except Exception:
return []
def _q1(conn, sql, params=()):
"""Single-Row-Query — gibt None zurück bei Fehler."""
try:
r = conn.execute(sql, params).fetchone()
return dict(r) if r else None
except Exception:
return None
uid = user['id']
with db() as conn:
# Nutzerprofil
u = _q1(conn,
"SELECT id, name, email, bio, wohnort, erfahrung, social_link, "
"email_verified, is_premium, subscription_tier, created_at "
"FROM users WHERE id=?", (uid,)) or {}
# Hunde
dogs_raw = _q(conn, "SELECT * FROM dogs WHERE user_id=?", (uid,))
dogs_out = []
for dog in dogs_raw:
did = dog['id']
# Tagebuch (nur vorhandene Spalten)
diary_rows = _q(conn,
"SELECT id, datum, typ, titel, text, gps_lat, gps_lon, "
"is_milestone, created_at FROM diary WHERE dog_id=?", (did,))
for de in diary_rows:
# diary_media: preview_url existiert nicht → url + media_type
de['media'] = _q(conn,
"SELECT url, media_type FROM diary_media WHERE diary_id=?",
(de['id'],))
# Gesundheit (alle via Migration ergänzten Spalten schützen)
health_rows = _q(conn,
"SELECT id, typ, bezeichnung, datum, naechstes, notiz FROM health "
"WHERE dog_id=?", (did,))
for he in health_rows:
he['media'] = _q(conn,
"SELECT url, media_type FROM health_media WHERE health_id=?",
(he['id'],))
dog['tagebuch'] = diary_rows
dog['gesundheit'] = health_rows
dog['trainingsfortschritt'] = _q(conn,
"SELECT exercise_id, status, updated_at FROM exercise_progress "
"WHERE dog_id=?", (did,))
dog['ausgaben'] = _q(conn,
"SELECT datum, betrag, kategorie, notiz FROM expenses "
"WHERE dog_id=?", (did,))
dog['verhaltensprotokoll'] = _q(conn,
"SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz "
"FROM behavior_log WHERE dog_id=?", (did,))
dog['versicherung'] = _q(conn,
"SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen "
"FROM dog_insurance WHERE dog_id=?", (did,))
dog['ernaehrungsprofil'] = _q1(conn,
"SELECT futter_typ, marke, kcal_tag, portionen, notizen "
"FROM futter_profil WHERE dog_id=?", (did,))
dog['futter_eintraege'] = _q(conn,
"SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz "
"FROM futter_eintraege WHERE dog_id=?", (did,))
dog['futter_reaktionen'] = _q(conn,
"SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz "
"FROM futter_reaktionen WHERE dog_id=?", (did,))
dog['routen'] = _q(conn,
"SELECT r.name, r.distanz_km, date(r.created_at) AS datum "
"FROM routes r JOIN route_dogs rd ON rd.route_id=r.id "
"WHERE rd.dog_id=?", (did,))
dogs_out.append(dog)
forum = _q(conn,
"SELECT ft.title, fp.content, fp.created_at, "
"CASE WHEN fp.parent_id IS NULL THEN 'Thread' ELSE 'Antwort' END AS art "
"FROM forum_posts fp LEFT JOIN forum_threads ft ON ft.id=fp.thread_id "
"WHERE fp.user_id=? ORDER BY fp.created_at DESC", (uid,))
walk_participations = _q(conn,
"SELECT w.titel, w.datum, w.uhrzeit, w.ort_name "
"FROM walk_participants wp JOIN walks w ON w.id=wp.walk_id "
"WHERE wp.user_id=?", (uid,))
walk_photos = _q(conn,
"SELECT wp.url, w.datum AS walk_datum, w.titel AS walk_titel, wp.created_at "
"FROM walk_photos wp JOIN walks w ON w.id=wp.walk_id "
"WHERE wp.user_id=?", (uid,))
push_count = _q1(conn,
"SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id=?",
(uid,))
push_count = (push_count or {}).get('n', 0)
export = {
"export_erstellt": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"hinweis": "Dieser Export enthält alle personenbezogenen Daten deines Ban-Yaro-Kontos gemäß Art. 20 DSGVO.",
"profil": u,
"hunde": dogs_out,
"forum_beitraege": [dict(f) for f in forum],
"gassi_teilnahmen": [dict(w) for w in walk_participations],
"gassi_fotos": [dict(p) for p in walk_photos],
"push_subscriptions": push_count,
}
content = _json.dumps(export, ensure_ascii=False, indent=2, default=str)
today = _dt.utcnow().strftime("%Y-%m-%d")
filename = f"banyaro-export-{today}.json"
return _Response(
content = content,
media_type = "application/json",
headers = {"Content-Disposition": f'attachment; filename="{filename}"'},
)