Files
game-marathon/backend/app/api/v1/games.py

369 lines
13 KiB
Python

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)