213 lines
6.7 KiB
Python
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)
|