from fastapi import APIRouter, HTTPException from sqlalchemy import select from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge from app.schemas import ( ChallengeCreate, ChallengeUpdate, ChallengeResponse, MessageResponse, GameShort, ChallengePreview, ChallengesPreviewResponse, ChallengesSaveRequest, ) from app.services.gpt import GPTService router = APIRouter(tags=["challenges"]) gpt_service = GPTService() async def get_challenge_or_404(db, challenge_id: int) -> Challenge: result = await db.execute( select(Challenge) .options(selectinload(Challenge.game)) .where(Challenge.id == challenge_id) ) challenge = result.scalar_one_or_none() if not challenge: raise HTTPException(status_code=404, detail="Challenge not found") return challenge @router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse]) async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession): """List challenges for a game. Participants can view challenges for approved games only.""" # Get game and check access result = await db.execute( select(Game).where(Game.id == game_id) ) game = result.scalar_one_or_none() if not game: raise HTTPException(status_code=404, detail="Game not found") participant = await get_participant(db, current_user.id, game.marathon_id) # Check access if not current_user.is_admin: if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Regular participants can only see challenges for approved games or their own games if not participant.is_organizer: if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id: raise HTTPException(status_code=403, detail="Game not accessible") result = await db.execute( select(Challenge) .where(Challenge.game_id == game_id) .order_by(Challenge.difficulty, Challenge.created_at) ) challenges = result.scalars().all() return [ ChallengeResponse( id=c.id, title=c.title, description=c.description, type=c.type, difficulty=c.difficulty, points=c.points, estimated_time=c.estimated_time, proof_type=c.proof_type, proof_hint=c.proof_hint, game=GameShort(id=game.id, title=game.title, cover_url=None), is_generated=c.is_generated, created_at=c.created_at, ) for c in challenges ] @router.post("/games/{game_id}/challenges", response_model=ChallengeResponse) async def create_challenge( game_id: int, data: ChallengeCreate, current_user: CurrentUser, db: DbSession, ): """Create a challenge for a game. Organizers only.""" # Get game and check access result = await db.execute( select(Game).where(Game.id == game_id) ) game = result.scalar_one_or_none() if not game: raise HTTPException(status_code=404, detail="Game not found") # Check marathon is in preparing state result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) marathon = result.scalar_one() if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon") # Only organizers can add challenges await require_organizer(db, current_user, game.marathon_id) # Can only add challenges to approved games if game.status != GameStatus.APPROVED.value: raise HTTPException(status_code=400, detail="Can only add challenges to approved games") challenge = Challenge( game_id=game_id, title=data.title, description=data.description, type=data.type.value, difficulty=data.difficulty.value, points=data.points, estimated_time=data.estimated_time, proof_type=data.proof_type.value, proof_hint=data.proof_hint, is_generated=False, ) db.add(challenge) await db.commit() await db.refresh(challenge) return 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, ) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" # Check marathon 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.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon") # Only organizers can generate challenges await require_organizer(db, current_user, marathon_id) # Get only APPROVED games result = await db.execute( select(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) games = result.scalars().all() if not games: raise HTTPException(status_code=400, detail="No approved games in marathon") preview_challenges = [] for game in games: # Check if game already has challenges existing = await db.scalar( select(Challenge.id).where(Challenge.game_id == game.id).limit(1) ) if existing: continue # Skip if already has challenges try: challenges_data = await gpt_service.generate_challenges(game.title, game.genre) for ch_data in challenges_data: preview_challenges.append(ChallengePreview( game_id=game.id, game_title=game.title, title=ch_data.title, description=ch_data.description, type=ch_data.type, difficulty=ch_data.difficulty, points=ch_data.points, estimated_time=ch_data.estimated_time, proof_type=ch_data.proof_type, proof_hint=ch_data.proof_hint, )) except Exception as e: # Log error but continue with other games print(f"Error generating challenges for {game.title}: {e}") return ChallengesPreviewResponse(challenges=preview_challenges) @router.post("/marathons/{marathon_id}/save-challenges", response_model=MessageResponse) async def save_challenges( marathon_id: int, data: ChallengesSaveRequest, current_user: CurrentUser, db: DbSession, ): """Save previewed challenges to database. Organizers only.""" # Check marathon 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.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon") # Only organizers can save challenges await require_organizer(db, current_user, marathon_id) # Verify all games belong to this marathon AND are approved result = await db.execute( select(Game.id).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) ) valid_game_ids = set(row[0] for row in result.fetchall()) saved_count = 0 for ch_data in data.challenges: if ch_data.game_id not in valid_game_ids: continue # Skip challenges for invalid/unapproved games # Validate type ch_type = ch_data.type if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]: ch_type = "completion" # Validate difficulty difficulty = ch_data.difficulty if difficulty not in ["easy", "medium", "hard"]: difficulty = "medium" # Validate proof_type proof_type = ch_data.proof_type if proof_type not in ["screenshot", "video", "steam"]: proof_type = "screenshot" challenge = Challenge( game_id=ch_data.game_id, title=ch_data.title[:100], description=ch_data.description, type=ch_type, difficulty=difficulty, points=max(1, min(500, ch_data.points)), estimated_time=ch_data.estimated_time, proof_type=proof_type, proof_hint=ch_data.proof_hint, is_generated=True, ) db.add(challenge) saved_count += 1 await db.commit() return MessageResponse(message=f"Сохранено {saved_count} заданий") @router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse) async def update_challenge( challenge_id: int, data: ChallengeUpdate, current_user: CurrentUser, db: DbSession, ): """Update a challenge. Organizers only.""" challenge = await get_challenge_or_404(db, challenge_id) # Check marathon is in preparing state result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) marathon = result.scalar_one() if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon") # Only organizers can update challenges await require_organizer(db, current_user, challenge.game.marathon_id) if data.title is not None: challenge.title = data.title if data.description is not None: challenge.description = data.description if data.type is not None: challenge.type = data.type.value if data.difficulty is not None: challenge.difficulty = data.difficulty.value if data.points is not None: challenge.points = data.points if data.estimated_time is not None: challenge.estimated_time = data.estimated_time if data.proof_type is not None: challenge.proof_type = data.proof_type.value if data.proof_hint is not None: challenge.proof_hint = data.proof_hint await db.commit() await db.refresh(challenge) game = challenge.game return 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, ) @router.delete("/challenges/{challenge_id}", response_model=MessageResponse) async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): """Delete a challenge. Organizers only.""" challenge = await get_challenge_or_404(db, challenge_id) # Check marathon is in preparing state result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) marathon = result.scalar_one() if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon") # Only organizers can delete challenges await require_organizer(db, current_user, challenge.game.marathon_id) await db.delete(challenge) await db.commit() return MessageResponse(message="Challenge deleted")