import random from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Form from sqlalchemy import select, func from sqlalchemy.orm import selectinload import uuid from pathlib import Path from app.api.deps import DbSession, CurrentUser from app.core.config import settings from app.models import ( Marathon, MarathonStatus, Game, Challenge, Participant, Assignment, AssignmentStatus, Activity, ActivityType ) from app.schemas import ( SpinResult, AssignmentResponse, CompleteResult, DropResult, GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse ) from app.services.points import PointsService router = APIRouter(tags=["wheel"]) points_service = PointsService() async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Participant: result = await db.execute( select(Participant).where( Participant.user_id == user_id, Participant.marathon_id == marathon_id, ) ) participant = result.scalar_one_or_none() if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") return participant async def get_active_assignment(db, participant_id: int) -> Assignment | None: result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game) ) .where( Assignment.participant_id == participant_id, Assignment.status == AssignmentStatus.ACTIVE.value, ) ) return result.scalar_one_or_none() @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): """Spin the wheel to get a random game and challenge""" # Check marathon is active result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) marathon = result.scalar_one_or_none() if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") if marathon.status != MarathonStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Marathon is not active") participant = await get_participant_or_403(db, current_user.id, marathon_id) # Check no active assignment active = await get_active_assignment(db, participant.id) if active: raise HTTPException(status_code=400, detail="You already have an active assignment") # Get all games with challenges result = await db.execute( select(Game) .options(selectinload(Game.challenges)) .where(Game.marathon_id == marathon_id) ) games = [g for g in result.scalars().all() if g.challenges] if not games: raise HTTPException(status_code=400, detail="No games with challenges available") # Random selection game = random.choice(games) challenge = random.choice(game.challenges) # Create assignment assignment = Assignment( participant_id=participant.id, challenge_id=challenge.id, status=AssignmentStatus.ACTIVE.value, ) db.add(assignment) # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.SPIN.value, data={ "game": game.title, "challenge": challenge.title, }, ) db.add(activity) await db.commit() await db.refresh(assignment) # Calculate drop penalty drop_penalty = points_service.calculate_drop_penalty(participant.drop_count) return SpinResult( assignment_id=assignment.id, game=GameResponse( id=game.id, title=game.title, cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, download_url=game.download_url, genre=game.genre, added_by=None, challenges_count=len(game.challenges), created_at=game.created_at, ), challenge=ChallengeResponse( id=challenge.id, title=challenge.title, description=challenge.description, type=challenge.type, difficulty=challenge.difficulty, points=challenge.points, estimated_time=challenge.estimated_time, proof_type=challenge.proof_type, proof_hint=challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None), is_generated=challenge.is_generated, created_at=challenge.created_at, ), can_drop=True, drop_penalty=drop_penalty, ) @router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None) async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): """Get current active assignment""" participant = await get_participant_or_403(db, current_user.id, marathon_id) assignment = await get_active_assignment(db, participant.id) if not assignment: return None challenge = assignment.challenge game = challenge.game return AssignmentResponse( id=assignment.id, challenge=ChallengeResponse( id=challenge.id, title=challenge.title, description=challenge.description, type=challenge.type, difficulty=challenge.difficulty, points=challenge.points, estimated_time=challenge.estimated_time, proof_type=challenge.proof_type, proof_hint=challenge.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None), is_generated=challenge.is_generated, created_at=challenge.created_at, ), status=assignment.status, proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, started_at=assignment.started_at, completed_at=assignment.completed_at, ) @router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult) async def complete_assignment( assignment_id: int, current_user: CurrentUser, db: DbSession, proof_url: str | None = Form(None), comment: str | None = Form(None), proof_file: UploadFile | None = File(None), ): """Complete an assignment with proof""" # Get assignment result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge), ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") if assignment.participant.user_id != current_user.id: raise HTTPException(status_code=403, detail="This is not your assignment") if assignment.status != AssignmentStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Assignment is not active") # Need either file or URL if not proof_file and not proof_url: raise HTTPException(status_code=400, detail="Proof is required (file or URL)") # Handle file upload if proof_file: contents = await proof_file.read() if len(contents) > settings.MAX_UPLOAD_SIZE: raise HTTPException( status_code=400, detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", ) ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" if ext not in settings.ALLOWED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", ) filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename filepath.parent.mkdir(parents=True, exist_ok=True) with open(filepath, "wb") as f: f.write(contents) assignment.proof_path = str(filepath) else: assignment.proof_url = proof_url assignment.proof_comment = comment # Calculate points participant = assignment.participant challenge = assignment.challenge total_points, streak_bonus = points_service.calculate_completion_points( challenge.points, participant.current_streak ) # Update assignment assignment.status = AssignmentStatus.COMPLETED.value assignment.points_earned = total_points assignment.streak_at_completion = participant.current_streak + 1 assignment.completed_at = datetime.utcnow() # Update participant participant.total_points += total_points participant.current_streak += 1 participant.drop_count = 0 # Reset drop counter on success # Get marathon_id for activity result = await db.execute( select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id) ) full_challenge = result.scalar_one() # Log activity activity = Activity( marathon_id=full_challenge.game.marathon_id, user_id=current_user.id, type=ActivityType.COMPLETE.value, data={ "challenge": challenge.title, "points": total_points, "streak": participant.current_streak, }, ) db.add(activity) await db.commit() return CompleteResult( points_earned=total_points, streak_bonus=streak_bonus, total_points=participant.total_points, new_streak=participant.current_streak, ) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult) async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): """Drop current assignment""" # Get assignment result = await db.execute( select(Assignment) .options( selectinload(Assignment.participant), selectinload(Assignment.challenge).selectinload(Challenge.game), ) .where(Assignment.id == assignment_id) ) assignment = result.scalar_one_or_none() if not assignment: raise HTTPException(status_code=404, detail="Assignment not found") if assignment.participant.user_id != current_user.id: raise HTTPException(status_code=403, detail="This is not your assignment") if assignment.status != AssignmentStatus.ACTIVE.value: raise HTTPException(status_code=400, detail="Assignment is not active") participant = assignment.participant # Calculate penalty penalty = points_service.calculate_drop_penalty(participant.drop_count) # Update assignment assignment.status = AssignmentStatus.DROPPED.value assignment.completed_at = datetime.utcnow() # Update participant participant.total_points = max(0, participant.total_points - penalty) participant.current_streak = 0 participant.drop_count += 1 # Log activity activity = Activity( marathon_id=assignment.challenge.game.marathon_id, user_id=current_user.id, type=ActivityType.DROP.value, data={ "challenge": assignment.challenge.title, "penalty": penalty, }, ) db.add(activity) await db.commit() return DropResult( penalty=penalty, total_points=participant.total_points, new_drop_count=participant.drop_count, ) @router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse]) async def get_my_history( marathon_id: int, current_user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0, ): """Get history of user's assignments in marathon""" participant = await get_participant_or_403(db, current_user.id, marathon_id) result = await db.execute( select(Assignment) .options( selectinload(Assignment.challenge).selectinload(Challenge.game) ) .where(Assignment.participant_id == participant.id) .order_by(Assignment.started_at.desc()) .limit(limit) .offset(offset) ) assignments = result.scalars().all() return [ AssignmentResponse( id=a.id, challenge=ChallengeResponse( id=a.challenge.id, title=a.challenge.title, description=a.challenge.description, type=a.challenge.type, difficulty=a.challenge.difficulty, points=a.challenge.points, estimated_time=a.challenge.estimated_time, proof_type=a.challenge.proof_type, proof_hint=a.challenge.proof_hint, game=GameShort( id=a.challenge.game.id, title=a.challenge.game.title, cover_url=None ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, ), status=a.status, proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, started_at=a.started_at, completed_at=a.completed_at, ) for a in assignments ]