Files

401 lines
14 KiB
Python
Raw Permalink Normal View History

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
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
2025-12-16 01:25:21 +07:00
from app.services.storage import storage_service
2025-12-17 19:50:55 +07:00
from app.services.telegram_notifier import telegram_notifier
2025-12-14 02:38:35 +07:00
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,
2025-12-16 01:25:21 +07:00
cover_url=storage_service.get_url(game.cover_path, "covers"),
2025-12-14 20:21:56 +07:00
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")
2025-12-17 19:50:55 +07:00
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id before status change
proposer_id = game.proposed_by_id
2025-12-14 20:21:56 +07:00
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)
2025-12-17 19:50:55 +07:00
# Notify proposer (if not self-approving)
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_approved(
db, proposer_id, marathon.title, game.title
)
2025-12-14 20:21:56 +07:00
# 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")
2025-12-17 19:50:55 +07:00
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id and game title before changes
proposer_id = game.proposed_by_id
game_title = game.title
2025-12-14 20:21:56 +07:00
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)
2025-12-17 19:50:55 +07:00
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
2025-12-14 20:21:56 +07:00
# 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}",
)
2025-12-16 01:25:21 +07:00
# Delete old cover if exists
if game.cover_path:
await storage_service.delete_file(game.cover_path)
# Upload file
filename = storage_service.generate_filename(game_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
2025-12-14 02:38:35 +07:00
2025-12-16 01:25:21 +07:00
game.cover_path = file_path
2025-12-14 02:38:35 +07:00
await db.commit()
return await get_game(game_id, current_user, db)