from __future__ import annotations from datetime import timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.orm import Session, selectinload from app.auth import generate_api_key_pair, get_active_user, get_current_user from app.database import get_db from app.models import ApiKey, Quiz, QuizAttempt, User, utcnow from app.schemas import ( ApiKeyCreatedOut, ApiKeyCreateIn, ApiKeyOut, ScoreSummaryOut, UserOut, UserScoreRowOut, UserUpdateIn, ) from app.services.quiz_service import enrich_quizzes router = APIRouter(prefix="/api/v1/users", tags=["Users & API Keys"]) def _api_key_is_active(key: ApiKey) -> bool: if key.revoked_at is not None: return False if key.expires_at is not None: exp = key.expires_at if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc) if exp <= utcnow(): return False return True def _serialize_api_key(key: ApiKey) -> ApiKeyOut: return ApiKeyOut( id=key.id, name=key.name, key_prefix=key.key_prefix, expires_at=key.expires_at, revoked_at=key.revoked_at, last_used_at=key.last_used_at, created_at=key.created_at, is_active=_api_key_is_active(key), ) @router.get("/me", response_model=UserOut) def get_me(user: User = Depends(get_current_user)) -> UserOut: return UserOut.model_validate(user) @router.patch("/me", response_model=UserOut) def update_me( payload: UserUpdateIn, db: Session = Depends(get_db), user: User = Depends(get_active_user), ) -> UserOut: if payload.nickname is not None and payload.nickname.lower() != user.nickname.lower(): conflict = db.scalar( select(User.id).where( func.lower(User.nickname) == payload.nickname.lower(), User.id != user.id, ) ) if conflict is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="That nickname is already taken by another player.", ) user.nickname = payload.nickname elif payload.nickname is not None: user.nickname = payload.nickname if payload.first_name is not None: user.first_name = payload.first_name.strip() if payload.last_name is not None: user.last_name = payload.last_name.strip() if payload.clear_age: user.age = None elif payload.age is not None: user.age = payload.age if payload.gender is not None: user.gender = payload.gender if payload.gender != "other": user.gender_custom = None if payload.gender_custom is not None: user.gender_custom = payload.gender_custom.strip() or None if payload.avatar_animal is not None: user.avatar_animal = payload.avatar_animal if payload.avatar_color is not None: user.avatar_color = payload.avatar_color if payload.public_website_consent is not None: user.public_website_consent = payload.public_website_consent user.consent_at = utcnow() if payload.public_website_consent else None if user.public_website_consent and user.nickname: user.onboarded = True db.commit() db.refresh(user) return UserOut.model_validate(user) @router.get("/me/scores", response_model=list[UserScoreRowOut]) def get_my_scores( db: Session = Depends(get_db), user: User = Depends(get_active_user), ) -> list[UserScoreRowOut]: attempts = db.scalars( select(QuizAttempt) .where(QuizAttempt.user_id == user.id) .order_by(QuizAttempt.completed_at.asc(), QuizAttempt.id.asc()) ).all() if not attempts: return [] by_quiz: dict[int, list[QuizAttempt]] = {} for att in attempts: by_quiz.setdefault(att.quiz_id, []).append(att) quizzes = db.scalars( select(Quiz) .options(selectinload(Quiz.author), selectinload(Quiz.questions)) .where(Quiz.id.in_(list(by_quiz.keys())), Quiz.is_removed.is_(False)) ).all() summaries = {s.id: s for s in enrich_quizzes(db, list(quizzes), user)} rows: list[UserScoreRowOut] = [] for q in quizzes: summary = summaries.get(q.id) if not summary or not summary.my_latest_score or not summary.my_best_score: continue rows.append( UserScoreRowOut( quiz_id=q.id, share_slug=q.share_slug, quiz_title=q.title, question_count=summary.question_count, latest_score=ScoreSummaryOut(**summary.my_latest_score.model_dump()), best_score=ScoreSummaryOut(**summary.my_best_score.model_dump()), global_best_score=summary.global_best_score, attempts_count=len(by_quiz.get(q.id, [])), ) ) rows.sort(key=lambda r: r.latest_score.completed_at, reverse=True) return rows @router.get("/me/api-keys", response_model=list[ApiKeyOut]) def list_api_keys( db: Session = Depends(get_db), user: User = Depends(get_active_user), ) -> list[ApiKeyOut]: keys = db.scalars( select(ApiKey) .where(ApiKey.user_id == user.id) .order_by(ApiKey.created_at.desc(), ApiKey.id.desc()) ).all() return [_serialize_api_key(k) for k in keys] @router.post("/me/api-keys", response_model=ApiKeyCreatedOut, status_code=status.HTTP_201_CREATED) def create_api_key( payload: ApiKeyCreateIn, db: Session = Depends(get_db), user: User = Depends(get_active_user), ) -> ApiKeyCreatedOut: raw_key, prefix, digest = generate_api_key_pair() expires_at = ( utcnow() + timedelta(days=payload.expires_in_days) if payload.expires_in_days is not None else None ) api_key = ApiKey( user_id=user.id, name=payload.name.strip(), key_prefix=prefix, key_hash=digest, expires_at=expires_at, ) db.add(api_key) db.commit() db.refresh(api_key) base = _serialize_api_key(api_key) return ApiKeyCreatedOut(**base.model_dump(), raw_key=raw_key) @router.post("/me/api-keys/{key_id}/expire", response_model=ApiKeyOut) def expire_api_key( key_id: int, db: Session = Depends(get_db), user: User = Depends(get_active_user), ) -> ApiKeyOut: api_key = db.scalar(select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user.id)) if api_key is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="API key not found.", ) if api_key.revoked_at is None: api_key.revoked_at = utcnow() db.commit() db.refresh(api_key) return _serialize_api_key(api_key)