from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query from sqlalchemy import select, func from sqlalchemy.orm import selectinload import uuid from pathlib import Path from app.api.deps import ( DbSession, CurrentUser, require_participant, require_organizer, get_participant, ) from app.core.config import settings from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic router = APIRouter(tags=["games"]) async def get_game_or_404(db, game_id: int) -> Game: result = await db.execute( select(Game) .options( selectinload(Game.proposed_by), selectinload(Game.approved_by), ) .where(Game.id == game_id) ) game = result.scalar_one_or_none() if not game: raise HTTPException(status_code=404, detail="Game not found") return game def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse: """Convert Game model to GameResponse schema""" return 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, status=game.status, proposed_by=UserPublic.model_validate(game.proposed_by) if game.proposed_by else None, approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None, challenges_count=challenges_count, created_at=game.created_at, ) @router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse]) async def list_games( marathon_id: int, current_user: CurrentUser, db: DbSession, status_filter: str | None = Query(None, alias="status"), ): """List games in marathon. Organizers/admins see all, participants see only approved.""" # Admins can view without being 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") query = ( select(Game, func.count(Challenge.id).label("challenges_count")) .outerjoin(Challenge) .options( selectinload(Game.proposed_by), selectinload(Game.approved_by), ) .where(Game.marathon_id == marathon_id) .group_by(Game.id) .order_by(Game.created_at.desc()) ) # Filter by status if provided is_organizer = current_user.is_admin or (participant and participant.is_organizer) if status_filter: query = query.where(Game.status == status_filter) elif not is_organizer: # Regular participants only see approved games + their own pending games query = query.where( (Game.status == GameStatus.APPROVED.value) | (Game.proposed_by_id == current_user.id) ) result = await db.execute(query) return [game_to_response(row[0], row[1]) for row in result.all()] @router.get("/marathons/{marathon_id}/games/pending", response_model=list[GameResponse]) async def list_pending_games(marathon_id: int, current_user: CurrentUser, db: DbSession): """List pending games for moderation. Organizers only.""" await require_organizer(db, current_user, marathon_id) result = await db.execute( select(Game, func.count(Challenge.id).label("challenges_count")) .outerjoin(Challenge) .options( selectinload(Game.proposed_by), selectinload(Game.approved_by), ) .where( Game.marathon_id == marathon_id, Game.status == GameStatus.PENDING.value, ) .group_by(Game.id) .order_by(Game.created_at.desc()) ) return [game_to_response(row[0], row[1]) for row in result.all()] @router.post("/marathons/{marathon_id}/games", response_model=GameResponse) async def add_game( marathon_id: int, data: GameCreate, current_user: CurrentUser, db: DbSession, ): """Propose a new game. Organizers can auto-approve.""" # Check marathon exists and is preparing 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 games to active or finished marathon") participant = await require_participant(db, current_user.id, marathon_id) # Check if user can propose games based on marathon settings is_organizer = participant.is_organizer or current_user.is_admin if marathon.game_proposal_mode == GameProposalMode.ORGANIZER_ONLY.value and not is_organizer: raise HTTPException(status_code=403, detail="Only organizers can add games to this marathon") # Organizers can auto-approve their games game_status = GameStatus.APPROVED.value if is_organizer else GameStatus.PENDING.value game = Game( marathon_id=marathon_id, title=data.title, download_url=data.download_url, genre=data.genre, proposed_by_id=current_user.id, status=game_status, approved_by_id=current_user.id if is_organizer else None, ) db.add(game) # Log activity activity = Activity( marathon_id=marathon_id, user_id=current_user.id, type=ActivityType.ADD_GAME.value, data={"title": game.title, "status": game_status}, ) db.add(activity) await db.commit() await db.refresh(game) return GameResponse( id=game.id, title=game.title, cover_url=None, download_url=game.download_url, genre=game.genre, status=game.status, proposed_by=UserPublic.model_validate(current_user), approved_by=UserPublic.model_validate(current_user) if is_organizer else None, challenges_count=0, created_at=game.created_at, ) @router.get("/games/{game_id}", response_model=GameResponse) async def get_game(game_id: int, current_user: CurrentUser, db: DbSession): game = await get_game_or_404(db, game_id) participant = await get_participant(db, current_user.id, game.marathon_id) # Check access: organizers see all, participants see approved + own if not current_user.is_admin: if not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") 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 found") challenges_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id) ) return game_to_response(game, challenges_count) @router.patch("/games/{game_id}", response_model=GameResponse) async def update_game( game_id: int, data: GameUpdate, current_user: CurrentUser, db: DbSession, ): game = await get_game_or_404(db, game_id) # Check if 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 update games in active or finished marathon") participant = await get_participant(db, current_user.id, game.marathon_id) # Only the one who proposed, organizers, or admin can update can_update = ( current_user.is_admin or (participant and participant.is_organizer) or game.proposed_by_id == current_user.id ) if not can_update: raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can update it") if data.title is not None: game.title = data.title if data.download_url is not None: game.download_url = data.download_url if data.genre is not None: game.genre = data.genre await db.commit() return await get_game(game_id, current_user, db) @router.delete("/games/{game_id}", response_model=MessageResponse) async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession): game = await get_game_or_404(db, game_id) # Check if 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 delete games from active or finished marathon") participant = await get_participant(db, current_user.id, game.marathon_id) # Only the one who proposed, organizers, or admin can delete can_delete = ( current_user.is_admin or (participant and participant.is_organizer) or game.proposed_by_id == current_user.id ) if not can_delete: raise HTTPException(status_code=403, detail="Only the one who proposed the game or organizer can delete it") await db.delete(game) await db.commit() return MessageResponse(message="Game deleted") @router.post("/games/{game_id}/approve", response_model=GameResponse) async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession): """Approve a pending game. Organizers only.""" game = await get_game_or_404(db, game_id) await require_organizer(db, current_user, game.marathon_id) if game.status != GameStatus.PENDING.value: raise HTTPException(status_code=400, detail="Game is not pending") game.status = GameStatus.APPROVED.value game.approved_by_id = current_user.id # Log activity activity = Activity( marathon_id=game.marathon_id, user_id=current_user.id, type=ActivityType.APPROVE_GAME.value, data={"title": game.title}, ) db.add(activity) await db.commit() await db.refresh(game) # Need to reload relationships game = await get_game_or_404(db, game_id) challenges_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id) ) return game_to_response(game, challenges_count) @router.post("/games/{game_id}/reject", response_model=GameResponse) async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession): """Reject a pending game. Organizers only.""" game = await get_game_or_404(db, game_id) await require_organizer(db, current_user, game.marathon_id) if game.status != GameStatus.PENDING.value: raise HTTPException(status_code=400, detail="Game is not pending") game.status = GameStatus.REJECTED.value # Log activity activity = Activity( marathon_id=game.marathon_id, user_id=current_user.id, type=ActivityType.REJECT_GAME.value, data={"title": game.title}, ) db.add(activity) await db.commit() await db.refresh(game) # Need to reload relationships game = await get_game_or_404(db, game_id) challenges_count = await db.scalar( select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id) ) return game_to_response(game, challenges_count) @router.post("/games/{game_id}/cover", response_model=GameResponse) async def upload_cover( game_id: int, current_user: CurrentUser, db: DbSession, file: UploadFile = File(...), ): game = await get_game_or_404(db, game_id) await require_participant(db, current_user.id, game.marathon_id) # Validate file if not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="File must be an image") contents = await 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 = file.filename.split(".")[-1].lower() if file.filename else "jpg" if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: raise HTTPException( status_code=400, detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", ) # Save file filename = f"{game_id}_{uuid.uuid4().hex}.{ext}" filepath = Path(settings.UPLOAD_DIR) / "covers" / filename filepath.parent.mkdir(parents=True, exist_ok=True) with open(filepath, "wb") as f: f.write(contents) game.cover_path = str(filepath) await db.commit() return await get_game(game_id, current_user, db)