2025-12-14 20:21:56 +07:00
|
|
|
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
|
2025-12-14 02:38:35 +07:00
|
|
|
from sqlalchemy import select, func
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
from app.api.deps import (
|
|
|
|
|
DbSession, CurrentUser,
|
|
|
|
|
require_participant, require_organizer, get_participant,
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
from app.core.config import settings
|
2025-12-14 20:21:56 +07:00
|
|
|
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
2025-12-14 02:38:35 +07:00
|
|
|
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)
|
2025-12-14 20:21:56 +07:00
|
|
|
.options(
|
|
|
|
|
selectinload(Game.proposed_by),
|
|
|
|
|
selectinload(Game.approved_by),
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
.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
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
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,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
|
2025-12-14 20:21:56 +07:00
|
|
|
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")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
query = (
|
2025-12-14 02:38:35 +07:00
|
|
|
select(Game, func.count(Challenge.id).label("challenges_count"))
|
|
|
|
|
.outerjoin(Challenge)
|
2025-12-14 20:21:56 +07:00
|
|
|
.options(
|
|
|
|
|
selectinload(Game.proposed_by),
|
|
|
|
|
selectinload(Game.approved_by),
|
|
|
|
|
)
|
2025-12-14 02:38:35 +07:00
|
|
|
.where(Game.marathon_id == marathon_id)
|
|
|
|
|
.group_by(Game.id)
|
|
|
|
|
.order_by(Game.created_at.desc())
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
# 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()]
|
2025-12-14 02:38:35 +07:00
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
@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()]
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
|
|
|
|
|
async def add_game(
|
|
|
|
|
marathon_id: int,
|
|
|
|
|
data: GameCreate,
|
|
|
|
|
current_user: CurrentUser,
|
|
|
|
|
db: DbSession,
|
|
|
|
|
):
|
2025-12-14 20:21:56 +07:00
|
|
|
"""Propose a new game. Organizers can auto-approve."""
|
2025-12-14 02:38:35 +07:00
|
|
|
# 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")
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
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
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
game = Game(
|
|
|
|
|
marathon_id=marathon_id,
|
|
|
|
|
title=data.title,
|
|
|
|
|
download_url=data.download_url,
|
|
|
|
|
genre=data.genre,
|
2025-12-14 20:21:56 +07:00
|
|
|
proposed_by_id=current_user.id,
|
|
|
|
|
status=game_status,
|
|
|
|
|
approved_by_id=current_user.id if is_organizer else None,
|
2025-12-14 02:38:35 +07:00
|
|
|
)
|
|
|
|
|
db.add(game)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
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,
|
2025-12-14 20:21:56 +07:00
|
|
|
status=game.status,
|
|
|
|
|
proposed_by=UserPublic.model_validate(current_user),
|
|
|
|
|
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
2025-12-14 02:38:35 +07:00
|
|
|
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)
|
2025-12-14 20:21:56 +07:00
|
|
|
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")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
challenges_count = await db.scalar(
|
|
|
|
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
return game_to_response(game, challenges_count)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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")
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
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")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
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")
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
await db.delete(game)
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
return MessageResponse(message="Game deleted")
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
@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)
|
2025-12-14 20:21:56 +07:00
|
|
|
await require_participant(db, current_user.id, game.marathon_id)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
# 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)
|