From 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 17 Apr 2026 09:18:53 +0200 Subject: [PATCH] Sprint 14: User-Profil-System (bio, wohnort, erfahrung, avatar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB-Migrationen: 7 neue Spalten in users (bio, wohnort, erfahrung, social_link, profil_sichtbarkeit, avatar_url, email_verified) - GET /api/auth/me gibt nun alle Profil-Felder + created_at zurück - PATCH /api/profile: Profil-Felder aktualisieren (mit Validierung) - POST /api/profile/avatar: Avatar-Upload (PIL JPEG-Konvertierung, HEIC-Support) - Router in main.py registriert --- backend/database.py | 8 +++ backend/main.py | 2 + backend/routes/auth.py | 20 ++++--- backend/routes/profile.py | 111 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 backend/routes/profile.py diff --git a/backend/database.py b/backend/database.py index e1f3405..9fe0d24 100644 --- a/backend/database.py +++ b/backend/database.py @@ -444,6 +444,14 @@ def _migrate(conn_factory): ("users", "ban_reason", "TEXT"), # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), + # User-Profil-Felder + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "bio", "TEXT"), + ("users", "wohnort", "TEXT"), + ("users", "erfahrung", "TEXT"), + ("users", "social_link", "TEXT"), + ("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"), + ("users", "avatar_url", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index a115694..2b2b35c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -72,6 +72,7 @@ from routes.friends import router as friends_router from routes.chat import router as chat_router from routes.admin import router as admin_router from routes.webcal import router as webcal_router +from routes.profile import router as profile_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -96,6 +97,7 @@ app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"] app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) +app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) # ------------------------------------------------------------------ diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 5bc3abc..501f06a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -92,10 +92,16 @@ async def logout(response: Response): @router.get("/me") async def me(user=Depends(get_current_user)): - return { - "id": user["id"], - "name": user["name"], - "email": user["email"], - "rolle": user["rolle"], - "is_premium": bool(user["is_premium"]), - } + with db() as conn: + row = conn.execute( + """SELECT id, name, email, rolle, is_premium, email_verified, + bio, wohnort, erfahrung, social_link, + profil_sichtbarkeit, avatar_url, created_at + 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 diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..1be34d4 --- /dev/null +++ b/backend/routes/profile.py @@ -0,0 +1,111 @@ +"""BAN YARO — User-Profil Routes""" + +import io +import os +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel + +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): + bio: Optional[str] = None + wohnort: Optional[str] = None + erfahrung: Optional[str] = None + social_link: Optional[str] = None + profil_sichtbarkeit: Optional[str] = None + + +def _load_user(user_id: int) -> dict: + with db() as conn: + row = conn.execute( + """SELECT id, name, email, rolle, is_premium, email_verified, + bio, wohnort, erfahrung, social_link, + profil_sichtbarkeit, avatar_url, created_at + 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.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 "bio" in fields and len(fields["bio"]) > 300: + raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.") + if "wohnort" in fields and len(fields["wohnort"]) > 60: + raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.") + if "social_link" in fields and len(fields["social_link"]) > 120: + raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.") + + 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 + + content = await file.read() + try: + img = Image.open(io.BytesIO(content)).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}