223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
|
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
||
|
|
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
|
||
|
|
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.added_by_user))
|
||
|
|
.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
|
||
|
|
|
||
|
|
|
||
|
|
async def check_participant(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
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
||
|
|
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||
|
|
await check_participant(db, current_user.id, marathon_id)
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
||
|
|
.outerjoin(Challenge)
|
||
|
|
.options(selectinload(Game.added_by_user))
|
||
|
|
.where(Game.marathon_id == marathon_id)
|
||
|
|
.group_by(Game.id)
|
||
|
|
.order_by(Game.created_at.desc())
|
||
|
|
)
|
||
|
|
|
||
|
|
games = []
|
||
|
|
for row in result.all():
|
||
|
|
game = row[0]
|
||
|
|
games.append(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=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
||
|
|
challenges_count=row[1],
|
||
|
|
created_at=game.created_at,
|
||
|
|
))
|
||
|
|
|
||
|
|
return games
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
|
||
|
|
async def add_game(
|
||
|
|
marathon_id: int,
|
||
|
|
data: GameCreate,
|
||
|
|
current_user: CurrentUser,
|
||
|
|
db: DbSession,
|
||
|
|
):
|
||
|
|
# 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")
|
||
|
|
|
||
|
|
await check_participant(db, current_user.id, marathon_id)
|
||
|
|
|
||
|
|
game = Game(
|
||
|
|
marathon_id=marathon_id,
|
||
|
|
title=data.title,
|
||
|
|
download_url=data.download_url,
|
||
|
|
genre=data.genre,
|
||
|
|
added_by_id=current_user.id,
|
||
|
|
)
|
||
|
|
db.add(game)
|
||
|
|
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,
|
||
|
|
added_by=UserPublic.model_validate(current_user),
|
||
|
|
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)
|
||
|
|
await check_participant(db, current_user.id, game.marathon_id)
|
||
|
|
|
||
|
|
challenges_count = await db.scalar(
|
||
|
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
||
|
|
)
|
||
|
|
|
||
|
|
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,
|
||
|
|
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
|
||
|
|
challenges_count=challenges_count,
|
||
|
|
created_at=game.created_at,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@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")
|
||
|
|
|
||
|
|
# Only the one who added or organizer can update
|
||
|
|
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
|
||
|
|
raise HTTPException(status_code=403, detail="Only the one who added 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")
|
||
|
|
|
||
|
|
# Only the one who added or organizer can delete
|
||
|
|
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
|
||
|
|
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it")
|
||
|
|
|
||
|
|
await db.delete(game)
|
||
|
|
await db.commit()
|
||
|
|
|
||
|
|
return MessageResponse(message="Game deleted")
|
||
|
|
|
||
|
|
|
||
|
|
@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 check_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)
|