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 from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType from app.schemas import ( MarathonCreate, MarathonUpdate, MarathonResponse, MarathonListItem, JoinMarathon, ParticipantInfo, ParticipantWithUser, LeaderboardEntry, MessageResponse, UserPublic, ) router = APIRouter(prefix="/marathons", tags=["marathons"]) 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.organizer)) .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 or organizer""" result = await db.execute( select(Marathon, func.count(Participant.id).label("participants_count")) .outerjoin(Participant) .where( (Marathon.organizer_id == current_user.id) | (Participant.user_id == current_user.id) ) .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, 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, organizer_id=current_user.id, invite_code=generate_invite_code(), start_date=start_date, end_date=end_date, ) db.add(marathon) await db.flush() # Auto-add organizer as participant participant = Participant( user_id=current_user.id, marathon_id=marathon.id, ) db.add(participant) await db.commit() await db.refresh(marathon) return MarathonResponse( id=marathon.id, title=marathon.title, description=marathon.description, organizer=UserPublic.model_validate(current_user), status=marathon.status, invite_code=marathon.invite_code, 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 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) ) # 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, organizer=UserPublic.model_validate(marathon.organizer), status=marathon.status, invite_code=marathon.invite_code, 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, ): marathon = await get_marathon_or_404(db, marathon_id) if marathon.organizer_id != current_user.id: raise HTTPException(status_code=403, detail="Only organizer can update marathon") 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 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): marathon = await get_marathon_or_404(db, marathon_id) if marathon.organizer_id != current_user.id: raise HTTPException(status_code=403, detail="Only organizer can delete marathon") 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): marathon = await get_marathon_or_404(db, marathon_id) if marathon.organizer_id != current_user.id: raise HTTPException(status_code=403, detail="Only organizer can start marathon") if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Marathon is not in preparing state") # Check if there are games with challenges games_count = await db.scalar( select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id) ) if games_count == 0: raise HTTPException(status_code=400, detail="Add 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): marathon = await get_marathon_or_404(db, marathon_id) if marathon.organizer_id != current_user.id: raise HTTPException(status_code=403, detail="Only organizer can finish marathon") 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, ) 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, 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.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