Sprint 14: User-Profil-System (bio, wohnort, erfahrung, avatar)

- 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
This commit is contained in:
rene 2026-04-17 09:18:53 +02:00
parent 41c4ba3dd6
commit 9bd8701a1d
4 changed files with 134 additions and 7 deletions

View file

@ -444,6 +444,14 @@ def _migrate(conn_factory):
("users", "ban_reason", "TEXT"), ("users", "ban_reason", "TEXT"),
# WebCal: Kalender-Abo-Token # WebCal: Kalender-Abo-Token
("users", "calendar_token", "TEXT"), ("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: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:

View file

@ -72,6 +72,7 @@ from routes.friends import router as friends_router
from routes.chat import router as chat_router from routes.chat import router as chat_router
from routes.admin import router as admin_router from routes.admin import router as admin_router
from routes.webcal import router as webcal_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(chat_router, prefix="/api/chat", tags=["Chat"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -92,10 +92,16 @@ async def logout(response: Response):
@router.get("/me") @router.get("/me")
async def me(user=Depends(get_current_user)): async def me(user=Depends(get_current_user)):
return { with db() as conn:
"id": user["id"], row = conn.execute(
"name": user["name"], """SELECT id, name, email, rolle, is_premium, email_verified,
"email": user["email"], bio, wohnort, erfahrung, social_link,
"rolle": user["rolle"], profil_sichtbarkeit, avatar_url, created_at
"is_premium": bool(user["is_premium"]), 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

111
backend/routes/profile.py Normal file
View file

@ -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}