trymeapp/app/routers/users.py

213 lines
6.7 KiB
Python

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)