from datetime import timedelta import secrets from fastapi import APIRouter, HTTPException, status from sqlalchemy import select, func from sqlalchemy.orm import selectinload from app.api.deps import ( DbSession, CurrentUser, require_participant, require_organizer, require_creator, get_participant, ) from app.models import ( Marathon, Participant, MarathonStatus, Game, GameStatus, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, ) from app.schemas import ( MarathonCreate, MarathonUpdate, MarathonResponse, MarathonListItem, MarathonPublicInfo, JoinMarathon, ParticipantInfo, ParticipantWithUser, LeaderboardEntry, MessageResponse, UserPublic, SetParticipantRole, ) router = APIRouter(prefix="/marathons", tags=["marathons"]) # Public endpoint (no auth required) @router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo) async def get_marathon_by_code(invite_code: str, db: DbSession): """Get public marathon info by invite code. No authentication required.""" result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .options(selectinload(Marathon.creator)) .where(Marathon.invite_code == invite_code) .group_by(Marathon.id) ) row = result.first() if not row: raise HTTPException(status_code=404, detail="Marathon not found") marathon = row[0] participants_count = row[1] return MarathonPublicInfo( id=marathon.id, title=marathon.title, description=marathon.description, status=marathon.status, participants_count=participants_count, creator_nickname=marathon.creator.nickname, ) def generate_invite_code() -> str: return secrets.token_urlsafe(8) async def get_marathon_or_404(db, marathon_id: int) -> Marathon: result = await db.execute( select(Marathon) .options(selectinload(Marathon.creator)) .where(Marathon.id == marathon_id) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") return marathon async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None: result = await db.execute( select(Participant).where( Participant.user_id == user_id, Participant.marathon_id == marathon_id, ) ) return result.scalar_one_or_none() @router.get("", response_model=list[MarathonListItem]) async def list_marathons(current_user: CurrentUser, db: DbSession): """Get all marathons where user is participant, creator, or public marathons""" # Admin can see all marathons if current_user.is_admin: result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) else: # User can see: own marathons, participated marathons, and public marathons result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .where( (Marathon.creator_id == current_user.id) | (Participant.user_id == current_user.id) | (Marathon.is_public == True) ) .group_by(Marathon.id) .order_by(Marathon.created_at.desc()) ) marathons = [] for row in result.all(): marathon = row[0] marathons.append(MarathonListItem( id=marathon.id, title=marathon.title, status=marathon.status, is_public=marathon.is_public, participants_count=row[1], start_date=marathon.start_date, end_date=marathon.end_date, )) return marathons @router.post("", response_model=MarathonResponse) async def create_marathon( data: MarathonCreate, current_user: CurrentUser, db: DbSession, ): # Strip timezone info for naive datetime columns start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date end_date = start_date + timedelta(days=data.duration_days) marathon = Marathon( title=data.title, description=data.description, creator_id=current_user.id, invite_code=generate_invite_code(), is_public=data.is_public, game_proposal_mode=data.game_proposal_mode, start_date=start_date, end_date=end_date, ) db.add(marathon) await db.flush() # Auto-add creator as organizer participant participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.ORGANIZER.value, # Creator is organizer ) db.add(participant) await db.commit() await db.refresh(marathon) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(current_user), status=marathon.status, invite_code=marathon.invite_code, is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=1, games_count=0, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participant), ) @router.get("/{marathon_id}", response_model=MarathonResponse) async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): marathon = await get_marathon_or_404(db, marathon_id) # Count participants and approved games participants_count = await db.scalar( select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) ) games_count = await db.scalar( select(func.count()).select_from(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) # Get user's participation participation = await get_participation(db, current_user.id, marathon_id) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, creator=UserPublic.model_validate(marathon.creator), status=marathon.status, invite_code=marathon.invite_code, is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=participants_count, games_count=games_count, created_at=marathon.created_at, my_participation=ParticipantInfo.model_validate(participation) if participation else None, ) @router.patch("/{marathon_id}", response_model=MarathonResponse) async def update_marathon( marathon_id: int, data: MarathonUpdate, current_user: CurrentUser, db: DbSession, ): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot update active or finished marathon") if data.title is not None: marathon.title = data.title if data.description is not None: marathon.description = data.description if data.start_date is not None: # Strip timezone info for naive datetime columns marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date if data.is_public is not None: marathon.is_public = data.is_public if data.game_proposal_mode is not None: marathon.game_proposal_mode = data.game_proposal_mode if data.auto_events_enabled is not None: marathon.auto_events_enabled = data.auto_events_enabled await db.commit() return await get_marathon(marathon_id, current_user, db) @router.delete("/{marathon_id}", response_model=MessageResponse) async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Only creator or admin can delete await require_creator(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) await db.delete(marathon) await db.commit() return MessageResponse(message="Marathon deleted") @router.post("/{marathon_id}/start", response_model=MarathonResponse) async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Marathon is not in preparing state") # Check if there are approved games with challenges games_count = await db.scalar( select(func.count()).select_from(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) if games_count == 0: raise HTTPException(status_code=400, detail="Add and approve at least one game before starting") marathon.status = MarathonStatus.ACTIVE.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.START_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() return await get_marathon(marathon_id, current_user, db) @router.post("/{marathon_id}/finish", response_model=MarathonResponse) async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): # Require organizer role await require_organizer(db, current_user, marathon_id) marathon = await get_marathon_or_404(db, marathon_id) if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") marathon.status = MarathonStatus.FINISHED.value # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.FINISH_MARATHON.value, data={"title": marathon.title}, ) db.add(activity) await db.commit() return await get_marathon(marathon_id, current_user, db) @router.post("/join", response_model=MarathonResponse) async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): result = await db.execute( select(Marathon).where(Marathon.invite_code == data.invite_code) ) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Invalid invite code") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, # Regular participant ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.post("/{marathon_id}/join", response_model=MarathonResponse) async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): """Join a public marathon without invite code""" marathon = await get_marathon_or_404(db, marathon_id) if not marathon.is_public: raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.") if marathon.status == MarathonStatus.FINISHED.value: raise HTTPException(status_code=400, detail="Marathon has already finished") # Check if already participant existing = await get_participation(db, current_user.id, marathon.id) if existing: raise HTTPException(status_code=400, detail="Already joined this marathon") participant = Participant( user_id=current_user.id, marathon_id=marathon.id, role=ParticipantRole.PARTICIPANT.value, ) db.add(participant) # Log activity activity = Activity( marathon_id=marathon.id, user_id=current_user.id, type=ActivityType.JOIN.value, data={"nickname": current_user.nickname}, ) db.add(activity) await db.commit() return await get_marathon(marathon.id, current_user, db) @router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): await get_marathon_or_404(db, marathon_id) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.joined_at) ) participants = result.scalars().all() return [ ParticipantWithUser( id=p.id, role=p.role, total_points=p.total_points, current_streak=p.current_streak, drop_count=p.drop_count, joined_at=p.joined_at, user=UserPublic.model_validate(p.user), ) for p in participants ] @router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser) async def set_participant_role( marathon_id: int, user_id: int, data: SetParticipantRole, current_user: CurrentUser, db: DbSession, ): """Set participant's role (only creator can do this)""" # Only creator can change roles marathon = await require_creator(db, current_user, marathon_id) # Cannot change creator's role if user_id == marathon.creator_id: raise HTTPException(status_code=400, detail="Cannot change creator's role") # Get participant result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where( Participant.marathon_id == marathon_id, Participant.user_id == user_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=404, detail="Participant not found") participant.role = data.role await db.commit() await db.refresh(participant) return ParticipantWithUser( id=participant.id, role=participant.role, total_points=participant.total_points, current_streak=participant.current_streak, drop_count=participant.drop_count, joined_at=participant.joined_at, user=UserPublic.model_validate(participant.user), ) @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) async def get_leaderboard(marathon_id: int, db: DbSession): await get_marathon_or_404(db, marathon_id) result = await db.execute( select(Participant) .options(selectinload(Participant.user)) .where(Participant.marathon_id == marathon_id) .order_by(Participant.total_points.desc()) ) participants = result.scalars().all() leaderboard = [] for rank, p in enumerate(participants, 1): # Count completed and dropped assignments completed = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.COMPLETED.value, ) ) dropped = await db.scalar( select(func.count()).select_from(Assignment).where( Assignment.participant_id == p.id, Assignment.status == AssignmentStatus.DROPPED.value, ) ) leaderboard.append(LeaderboardEntry( rank=rank, user=UserPublic.model_validate(p.user), total_points=p.total_points, current_streak=p.current_streak, completed_count=completed, dropped_count=dropped, )) return leaderboard