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, User from app.models.challenge import ChallengeStatus from app.schemas import ( ChallengeCreate, ChallengeUpdate, ChallengeResponse, MessageResponse, GameShort, ChallengePreview, ChallengesPreviewResponse, ChallengesSaveRequest, ChallengesGenerateRequest, ) from app.schemas.challenge import ChallengePropose, ProposedByUser from app.services.gpt import gpt_service from app.services.telegram_notifier import telegram_notifier router = APIRouter(tags=["challenges"]) async def get_challenge_or_404(db, challenge_id: int) -> Challenge: result = await db.execute( select(Challenge) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .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 def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse: """Helper to build ChallengeResponse with proposed_by""" proposed_by = None if challenge.proposed_by: proposed_by = ProposedByUser( id=challenge.proposed_by.id, nickname=challenge.proposed_by.nickname ) 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, status=challenge.status, proposed_by=proposed_by, ) @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 approved and pending challenges.""" # 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") # Get challenges with proposed_by query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id) # Regular participants see approved and pending challenges (but not rejected) if not current_user.is_admin and participant and not participant.is_organizer: query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value])) result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at)) challenges = result.scalars().all() return [build_challenge_response(c, game) for c in challenges] @router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse]) async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): """List all challenges for a marathon (from all approved games). Participants only.""" # Check marathon exists 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") # Check user is participant or admin participant = await get_participant(db, current_user.id, marathon_id) if not current_user.is_admin and not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Get all approved challenges from approved games in this marathon result = await db.execute( select(Challenge) .join(Game, Challenge.game_id == Game.id) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, Challenge.status == ChallengeStatus.APPROVED.value, ) .order_by(Game.title, Challenge.difficulty, Challenge.created_at) ) challenges = result.scalars().all() return [build_challenge_response(c, c.game) 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, status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved ) db.add(challenge) await db.commit() await db.refresh(challenge) return build_challenge_response(challenge, game) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) async def preview_challenges( marathon_id: int, current_user: CurrentUser, db: DbSession, data: ChallengesGenerateRequest | None = None, ): """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 query = select(Game).where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, ) # Filter by specific game IDs if provided if data and data.game_ids: query = query.where(Game.id.in_(data.game_ids)) result = await db.execute(query) games = result.scalars().all() if not games: raise HTTPException(status_code=400, detail="No approved games found") # Build games list for generation (skip games that already have challenges, unless specific IDs requested) games_to_generate = [] game_map = {} for game in games: # If specific games requested, generate even if they have challenges if data and data.game_ids: games_to_generate.append({ "id": game.id, "title": game.title, "genre": game.genre }) game_map[game.id] = game.title else: # Otherwise only generate for games without challenges existing = await db.scalar( select(Challenge.id).where(Challenge.game_id == game.id).limit(1) ) if not existing: games_to_generate.append({ "id": game.id, "title": game.title, "genre": game.genre }) game_map[game.id] = game.title if not games_to_generate: return ChallengesPreviewResponse(challenges=[]) # Generate challenges for all games in one API call preview_challenges = [] try: challenges_by_game = await gpt_service.generate_challenges(games_to_generate) for game_id, challenges_data in challenges_by_game.items(): game_title = game_map.get(game_id, "Unknown") 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: print(f"Error generating challenges: {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) return build_challenge_response(challenge, challenge.game) @router.delete("/challenges/{challenge_id}", response_model=MessageResponse) async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): """Delete a challenge. Organizers can delete any, participants can delete their own pending.""" 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") participant = await get_participant(db, current_user.id, challenge.game.marathon_id) # Check permissions if current_user.is_admin or (participant and participant.is_organizer): # Organizers can delete any challenge pass elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value: # Participants can delete their own pending challenges pass else: raise HTTPException(status_code=403, detail="You can only delete your own pending challenges") await db.delete(challenge) await db.commit() return MessageResponse(message="Challenge deleted") # ============ Proposed challenges endpoints ============ @router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse) async def propose_challenge( game_id: int, data: ChallengePropose, current_user: CurrentUser, db: DbSession, ): """Propose a challenge for a game. Participants only, during PREPARING phase.""" # Get game 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 propose challenges to active or finished marathon") # Check user is participant participant = await get_participant(db, current_user.id, game.marathon_id) if not participant and not current_user.is_admin: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Can only propose challenges to approved games if game.status != GameStatus.APPROVED.value: raise HTTPException(status_code=400, detail="Can only propose 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, proposed_by_id=current_user.id, status=ChallengeStatus.PENDING.value, ) db.add(challenge) await db.commit() await db.refresh(challenge) # Load proposed_by relationship challenge.proposed_by = current_user return build_challenge_response(challenge, game) @router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse]) async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): """List all pending proposed challenges for a marathon. Organizers only.""" # Check marathon exists 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") # Only organizers can see all proposed challenges await require_organizer(db, current_user, marathon_id) # Get all pending challenges from approved games result = await db.execute( select(Challenge) .join(Game, Challenge.game_id == Game.id) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, Challenge.status == ChallengeStatus.PENDING.value, ) .order_by(Challenge.created_at.desc()) ) challenges = result.scalars().all() return [build_challenge_response(c, c.game) for c in challenges] @router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse]) async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): """List current user's proposed challenges for a marathon.""" # Check marathon exists 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") # Check user is participant participant = await get_participant(db, current_user.id, marathon_id) if not participant and not current_user.is_admin: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") # Get user's proposed challenges result = await db.execute( select(Challenge) .join(Game, Challenge.game_id == Game.id) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .where( Game.marathon_id == marathon_id, Challenge.proposed_by_id == current_user.id, ) .order_by(Challenge.created_at.desc()) ) challenges = result.scalars().all() return [build_challenge_response(c, c.game) for c in challenges] @router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse) async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): """Approve a proposed 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 approve challenges in active or finished marathon") # Only organizers can approve await require_organizer(db, current_user, challenge.game.marathon_id) if challenge.status != ChallengeStatus.PENDING.value: raise HTTPException(status_code=400, detail="Challenge is not pending") challenge.status = ChallengeStatus.APPROVED.value await db.commit() await db.refresh(challenge) # Send Telegram notification to proposer if challenge.proposed_by_id: await telegram_notifier.notify_challenge_approved( db, challenge.proposed_by_id, marathon.title, challenge.game.title, challenge.title ) return build_challenge_response(challenge, challenge.game) @router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse) async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): """Reject a proposed 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 reject challenges in active or finished marathon") # Only organizers can reject await require_organizer(db, current_user, challenge.game.marathon_id) if challenge.status != ChallengeStatus.PENDING.value: raise HTTPException(status_code=400, detail="Challenge is not pending") # Save info for notification before changing status proposer_id = challenge.proposed_by_id game_title = challenge.game.title challenge_title = challenge.title challenge.status = ChallengeStatus.REJECTED.value await db.commit() await db.refresh(challenge) # Send Telegram notification to proposer if proposer_id: await telegram_notifier.notify_challenge_rejected( db, proposer_id, marathon.title, game_title, challenge_title ) return build_challenge_response(challenge, challenge.game)