This commit is contained in:
2025-12-14 02:38:35 +07:00
commit 5343a8f2c3
84 changed files with 7406 additions and 0 deletions

222
backend/app/api/v1/games.py Normal file
View File

@@ -0,0 +1,222 @@
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)