from __future__ import annotations from datetime import date from typing import Any, Annotated from fastapi import APIRouter, Depends, Query, status from sqlalchemy import or_, select, text from sqlalchemy.orm import Session from fan_passport import models, schemas from fan_passport.api.dependencies import get_current_actor, get_db_session, require_admin from fan_passport.auth import Actor from fan_passport.content_importer import import_seed_payload from fan_passport.errors import NotFoundError from fan_passport.gamification import ( BadgeUnlock, ChallengeCompletion, GamificationService, ) from fan_passport.leaderboards import LeaderboardService router = APIRouter() def _badge_unlock_response(unlock: BadgeUnlock) -> schemas.BadgeUnlockResponse: return schemas.BadgeUnlockResponse( badge_id=unlock.badge.id, slug=unlock.badge.slug, title=unlock.badge.title, tier=unlock.badge.tier, icon=unlock.badge.icon, points_awarded=unlock.badge.points, unlocked_at=unlock.user_badge.unlocked_at, reason_json=unlock.user_badge.reason_json, ) def _challenge_completion_response( completion: ChallengeCompletion, ) -> schemas.ChallengeCompletionResponse: return schemas.ChallengeCompletionResponse( challenge_id=completion.challenge.id, slug=completion.challenge.slug, title=completion.challenge.title, category=completion.challenge.category, progress_count=completion.progress.progress_count, target_count=completion.progress.target_count, points_awarded=completion.progress.points_awarded, completed_at=completion.progress.completed_at, ) def _challenge_progress_response( progress: models.UserChallengeProgress, challenge: models.Challenge ) -> schemas.ChallengeProgressResponse: return schemas.ChallengeProgressResponse( challenge_id=challenge.id, slug=challenge.slug, title=challenge.title, description=challenge.description, category=challenge.category, progress_count=progress.progress_count, target_count=progress.target_count, completed=progress.completed, completed_at=progress.completed_at, points_awarded=progress.points_awarded, state_json=progress.state_json, ) def _user_badge_response(user_badge: models.UserBadge, badge: models.Badge) -> schemas.UserBadgeResponse: return schemas.UserBadgeResponse( user_badge_id=user_badge.id, badge_id=badge.id, slug=badge.slug, title=badge.title, description=badge.description, tier=badge.tier, icon=badge.icon, points_awarded=badge.points, unlocked_at=user_badge.unlocked_at, reason_json=user_badge.reason_json, ) @router.get("/health") def health(db: Annotated[Session, Depends(get_db_session)]) -> dict[str, str]: db.execute(text("SELECT 1")) return {"status": "ok"} @router.get("/content/competitions", response_model=list[schemas.CompetitionResponse]) def list_competitions(db: Annotated[Session, Depends(get_db_session)]) -> list[models.Competition]: return db.scalars(select(models.Competition).order_by(models.Competition.starts_at)).all() @router.get("/content/teams", response_model=list[schemas.TeamResponse]) def list_teams( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, group_name: str | None = None, active: bool = True, ) -> list[models.Team]: stmt = select(models.Team) if competition_code: stmt = stmt.where(models.Team.competition_code == competition_code) if group_name: stmt = stmt.where(models.Team.group_name == group_name) if active: stmt = stmt.where(models.Team.active.is_(True)) return db.scalars(stmt.order_by(models.Team.group_name, models.Team.name)).all() @router.get("/content/stadiums", response_model=list[schemas.StadiumResponse]) def list_stadiums( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, active: bool = True, ) -> list[models.Stadium]: stmt = select(models.Stadium) if competition_code: stmt = stmt.where(models.Stadium.competition_code == competition_code) if active: stmt = stmt.where(models.Stadium.active.is_(True)) return db.scalars(stmt.order_by(models.Stadium.country, models.Stadium.city)).all() @router.get("/content/stickers", response_model=list[schemas.StickerResponse]) def list_stickers( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, team_id: str | None = None, active: bool = True, ) -> list[models.Sticker]: stmt = select(models.Sticker) if competition_code: stmt = stmt.where(models.Sticker.competition_code == competition_code) if team_id: stmt = stmt.where(models.Sticker.team_id == team_id) if active: stmt = stmt.where(models.Sticker.active.is_(True)) return db.scalars(stmt.order_by(models.Sticker.rarity, models.Sticker.name)).all() @router.get("/content/matches", response_model=list[schemas.MatchResponse]) def list_matches( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, team_id: str | None = None, status_filter: str | None = Query(default=None, alias="status"), active: bool = True, ) -> list[models.Match]: stmt = select(models.Match) if competition_code: stmt = stmt.where(models.Match.competition_code == competition_code) if team_id: stmt = stmt.where(or_(models.Match.home_team_id == team_id, models.Match.away_team_id == team_id)) if status_filter: stmt = stmt.where(models.Match.status == status_filter) if active: stmt = stmt.where(models.Match.active.is_(True)) return db.scalars(stmt.order_by(models.Match.kickoff_at, models.Match.id)).all() @router.get("/content/challenges", response_model=list[schemas.ChallengeResponse]) def list_challenges( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, active: bool = True, ) -> list[models.Challenge]: stmt = select(models.Challenge) if competition_code: stmt = stmt.where(models.Challenge.competition_code == competition_code) if active: stmt = stmt.where(models.Challenge.active.is_(True)) return db.scalars(stmt.order_by(models.Challenge.category, models.Challenge.slug)).all() @router.get("/content/badges", response_model=list[schemas.BadgeResponse]) def list_badges( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, active: bool = True, ) -> list[models.Badge]: stmt = select(models.Badge) if competition_code: stmt = stmt.where(models.Badge.competition_code == competition_code) if active: stmt = stmt.where(models.Badge.active.is_(True)) return db.scalars(stmt.order_by(models.Badge.tier, models.Badge.slug)).all() @router.post( "/profiles", response_model=schemas.ProfileCreateResponse, status_code=status.HTTP_201_CREATED, ) def create_profile( payload: schemas.ProfileCreateRequest, db: Annotated[Session, Depends(get_db_session)], ) -> schemas.ProfileCreateResponse: result = GamificationService(db).create_profile(**payload.model_dump()) return schemas.ProfileCreateResponse( profile=schemas.ProfileResponse.model_validate(result.profile), created=result.created, new_badges=[_badge_unlock_response(item) for item in result.evaluation.new_badges], completed_challenges=[ _challenge_completion_response(item) for item in result.evaluation.completed_challenges ], ) @router.post("/me/profile", response_model=schemas.ProfileCreateResponse) def get_or_create_me_profile( payload: schemas.MeProfileRequest, actor: Annotated[Actor, Depends(get_current_actor)], db: Annotated[Session, Depends(get_db_session)], ) -> schemas.ProfileCreateResponse: result = GamificationService(db).get_or_create_profile_for_subject( external_subject=actor.subject, display_name=payload.display_name or actor.display_name or "Fan", country_code=payload.country_code, favorite_team_id=payload.favorite_team_id, avatar_url=payload.avatar_url, metadata_json=payload.metadata_json, ) return schemas.ProfileCreateResponse( profile=schemas.ProfileResponse.model_validate(result.profile), created=result.created, new_badges=[_badge_unlock_response(item) for item in result.evaluation.new_badges], completed_challenges=[ _challenge_completion_response(item) for item in result.evaluation.completed_challenges ], ) @router.get("/profiles/{user_id}", response_model=schemas.ProfileResponse) def get_profile( user_id: str, db: Annotated[Session, Depends(get_db_session)], ) -> models.UserProfile: return GamificationService(db).get_profile(user_id) @router.get("/users/{user_id}/passport", response_model=schemas.PassportProgressResponse) def get_passport( user_id: str, db: Annotated[Session, Depends(get_db_session)], ) -> schemas.PassportProgressResponse: snapshot = GamificationService(db).get_passport_snapshot(user_id) return schemas.PassportProgressResponse( profile=schemas.ProfileResponse.model_validate(snapshot.profile), collection_counts=snapshot.collection_counts, content_totals=snapshot.content_totals, collection_completion=snapshot.collection_completion, quiz=snapshot.quiz, predictions=snapshot.predictions, challenges=[ _challenge_progress_response(progress, challenge) for progress, challenge in snapshot.challenges ], badges=[_user_badge_response(user_badge, badge) for user_badge, badge in snapshot.badges], recent_events=[ schemas.AchievementEventResponse.model_validate(event) for event in snapshot.recent_events ], ) @router.get("/users/{user_id}/collections", response_model=list[schemas.CollectionItemResponse]) def list_collection_items( user_id: str, db: Annotated[Session, Depends(get_db_session)], item_type: str | None = None, ) -> list[models.UserCollectionItem]: return GamificationService(db).list_collection_items(user_id=user_id, item_type=item_type) @router.post("/users/{user_id}/collections", response_model=schemas.CollectionAddResponse) def add_collection_item( user_id: str, payload: schemas.CollectionAddRequest, db: Annotated[Session, Depends(get_db_session)], ) -> schemas.CollectionAddResponse: result = GamificationService(db).collect_item(user_id=user_id, **payload.model_dump()) return schemas.CollectionAddResponse( item=schemas.CollectionItemResponse.model_validate(result.item), duplicate=result.duplicate, points_awarded=result.points_awarded, profile=schemas.ProfileResponse.model_validate(result.profile), new_badges=[_badge_unlock_response(item) for item in result.evaluation.new_badges], completed_challenges=[ _challenge_completion_response(item) for item in result.evaluation.completed_challenges ], ) @router.get("/quiz/questions/daily", response_model=schemas.QuizQuestionPublicResponse) def get_daily_quiz_question( db: Annotated[Session, Depends(get_db_session)], on_date: date | None = Query(default=None, alias="date"), ) -> models.QuizQuestion: target_date = on_date or date.today() questions = db.scalars( select(models.QuizQuestion) .where(models.QuizQuestion.active.is_(True)) .order_by(models.QuizQuestion.scheduled_date, models.QuizQuestion.id) ).all() if not questions: raise NotFoundError("No active quiz questions are available.") exact = [question for question in questions if question.scheduled_date == target_date] if exact: return exact[0] eligible = [ question for question in questions if question.scheduled_date is None or question.scheduled_date <= target_date ] return eligible[-1] if eligible else questions[0] @router.get("/quiz/questions", response_model=list[schemas.QuizQuestionPublicResponse]) def list_quiz_questions( db: Annotated[Session, Depends(get_db_session)], competition_code: str | None = None, active: bool = True, ) -> list[models.QuizQuestion]: stmt = select(models.QuizQuestion) if competition_code: stmt = stmt.where(models.QuizQuestion.competition_code == competition_code) if active: stmt = stmt.where(models.QuizQuestion.active.is_(True)) return db.scalars(stmt.order_by(models.QuizQuestion.scheduled_date, models.QuizQuestion.id)).all() @router.post("/users/{user_id}/quiz/answers", response_model=schemas.QuizAnswerResultResponse) def answer_quiz( user_id: str, payload: schemas.QuizAnswerRequest, db: Annotated[Session, Depends(get_db_session)], ) -> schemas.QuizAnswerResultResponse: result = GamificationService(db).answer_quiz(user_id=user_id, **payload.model_dump()) return schemas.QuizAnswerResultResponse( answer=schemas.QuizAnswerResponse.model_validate(result.answer), correct=result.answer.is_correct, explanation=result.question.explanation, points_awarded=result.points_awarded, profile=schemas.ProfileResponse.model_validate(result.profile), new_badges=[_badge_unlock_response(item) for item in result.evaluation.new_badges], completed_challenges=[ _challenge_completion_response(item) for item in result.evaluation.completed_challenges ], ) @router.get("/users/{user_id}/predictions", response_model=list[schemas.PredictionResponse]) def list_predictions( user_id: str, db: Annotated[Session, Depends(get_db_session)], ) -> list[models.UserPrediction]: return GamificationService(db).list_predictions(user_id=user_id) @router.post("/users/{user_id}/predictions", response_model=schemas.PredictionSubmitResponse) def submit_prediction( user_id: str, payload: schemas.PredictionSubmitRequest, db: Annotated[Session, Depends(get_db_session)], ) -> schemas.PredictionSubmitResponse: result = GamificationService(db).submit_prediction(user_id=user_id, **payload.model_dump()) return schemas.PredictionSubmitResponse( prediction=schemas.PredictionResponse.model_validate(result.prediction), profile=schemas.ProfileResponse.model_validate(result.profile), ) @router.get("/users/{user_id}/achievements") def list_user_achievements( user_id: str, db: Annotated[Session, Depends(get_db_session)], ) -> dict[str, Any]: challenges, badges = GamificationService(db).list_achievements(user_id=user_id) return { "challenges": [ _challenge_progress_response(progress, challenge).model_dump(mode="json") for progress, challenge in challenges ], "badges": [ _user_badge_response(user_badge, badge).model_dump(mode="json") for user_badge, badge in badges ], } @router.post("/users/{user_id}/achievements/evaluate") def evaluate_user_achievements( user_id: str, db: Annotated[Session, Depends(get_db_session)], ) -> dict[str, Any]: summary = GamificationService(db).evaluate_user_and_commit(user_id) return { "points_awarded": summary.points_awarded, "new_badges": [_badge_unlock_response(item).model_dump(mode="json") for item in summary.new_badges], "completed_challenges": [ _challenge_completion_response(item).model_dump(mode="json") for item in summary.completed_challenges ], } @router.get("/leaderboards", response_model=list[schemas.LeaderboardEntryResponse]) def get_leaderboard( db: Annotated[Session, Depends(get_db_session)], metric: str = "points_total", limit: int = Query(default=50, ge=1, le=100), country_code: str | None = None, ) -> list[schemas.LeaderboardEntryResponse]: rows = LeaderboardService(db).get_leaderboard( metric=metric, limit=limit, country_code=country_code ) return [schemas.LeaderboardEntryResponse(**row.__dict__) for row in rows] @router.put( "/admin/matches/{match_id}/result", response_model=schemas.MatchResultEvaluationResponse, ) def admin_record_match_result( match_id: str, payload: schemas.MatchResultUpdateRequest, db: Annotated[Session, Depends(get_db_session)], admin: Annotated[Actor, Depends(require_admin)], ) -> schemas.MatchResultEvaluationResponse: result = GamificationService(db).record_match_result(match_id=match_id, **payload.model_dump()) db.add( models.AdminAuditLog( actor_subject=admin.subject, action="match_result_recorded", entity_type="match", entity_id=match_id, payload_json=payload.model_dump(), ) ) db.commit() return schemas.MatchResultEvaluationResponse( match=schemas.MatchResponse.model_validate(result.match), evaluated_predictions=result.evaluated_predictions, points_awarded_total=result.points_awarded_total, new_badges=[_badge_unlock_response(item) for item in result.evaluation.new_badges], completed_challenges=[ _challenge_completion_response(item) for item in result.evaluation.completed_challenges ], ) @router.post("/admin/content/import", response_model=schemas.SeedImportResponse) def admin_import_content( payload: dict[str, Any], db: Annotated[Session, Depends(get_db_session)], admin: Annotated[Actor, Depends(require_admin)], ) -> schemas.SeedImportResponse: result = import_seed_payload(db, payload) db.add( models.AdminAuditLog( actor_subject=admin.subject, action="content_imported", entity_type="seed_payload", entity_id=None, payload_json=result.as_dict(), ) ) db.commit() return schemas.SeedImportResponse(**result.as_dict()) @router.post( "/moderation/reports", response_model=schemas.ModerationReportResponse, status_code=status.HTTP_201_CREATED, ) def create_moderation_report( payload: schemas.ModerationReportCreateRequest, db: Annotated[Session, Depends(get_db_session)], ) -> models.ModerationReport: if payload.reporter_user_id: reporter = db.get(models.UserProfile, payload.reporter_user_id) if reporter is None: raise NotFoundError( "Reporter user profile not found.", details={"reporter_user_id": payload.reporter_user_id}, ) report = models.ModerationReport(**payload.model_dump()) db.add(report) db.commit() return report @router.get("/admin/moderation/reports", response_model=list[schemas.ModerationReportResponse]) def admin_list_moderation_reports( db: Annotated[Session, Depends(get_db_session)], admin: Annotated[Actor, Depends(require_admin)], status_filter: str | None = Query(default=None, alias="status"), ) -> list[models.ModerationReport]: _ = admin stmt = select(models.ModerationReport).order_by(models.ModerationReport.created_at.desc()) if status_filter: stmt = stmt.where(models.ModerationReport.status == status_filter) return db.scalars(stmt).all()