Files
game-marathon/backend/app/api/v1/games.py
mamonov.ep f78eacb1a5 Добавлен Skip with Exile, модерация марафонов и выдача предметов
## Skip with Exile (новый расходник)
- Новая модель ExiledGame для хранения изгнанных игр
- Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда
- Фильтрация изгнанных игр при выдаче заданий
- UI кнопка в PlayPage для использования skip_exile

## Модерация марафонов (для организаторов)
- Эндпоинты: skip-assignment, exiled-games, restore-exiled-game
- UI в LeaderboardPage: кнопка скипа у каждого участника
- Выбор типа скипа (обычный/с изгнанием) + причина
- Telegram уведомления о модерации

## Админская выдача предметов
- Эндпоинты: admin grant/remove items, get user inventory
- Новая страница AdminGrantItemPage (как магазин)
- Telegram уведомление при получении подарка

## Исправления миграций
- Миграции 029/030 теперь идемпотентны (проверка существования таблиц)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-10 23:02:37 +03:00

624 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
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, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User,
ExiledGame
)
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
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(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.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=storage_service.get_url(game.cover_path, "covers"),
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,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
@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(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.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(User.equipped_frame),
selectinload(Game.proposed_by).selectinload(User.equipped_title),
selectinload(Game.proposed_by).selectinload(User.equipped_name_color),
selectinload(Game.proposed_by).selectinload(User.equipped_background),
selectinload(Game.approved_by).selectinload(User.equipped_frame),
selectinload(Game.approved_by).selectinload(User.equipped_title),
selectinload(Game.approved_by).selectinload(User.equipped_name_color),
selectinload(Game.approved_by).selectinload(User.equipped_background),
)
.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,
# Поля для типа игры
game_type=data.game_type.value,
playthrough_points=data.playthrough_points,
playthrough_description=data.playthrough_description,
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
playthrough_proof_hint=data.playthrough_proof_hint,
)
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,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
@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
# Поля для типа игры
if data.game_type is not None:
game.game_type = data.game_type.value
if data.playthrough_points is not None:
game.playthrough_points = data.playthrough_points
if data.playthrough_description is not None:
game.playthrough_description = data.playthrough_description
if data.playthrough_proof_type is not None:
game.playthrough_proof_type = data.playthrough_proof_type.value
if data.playthrough_proof_hint is not None:
game.playthrough_proof_hint = data.playthrough_proof_hint
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")
# 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
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)
# 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
)
# 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")
# 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
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)
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
# 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}",
)
# 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",
)
game.cover_path = file_path
await db.commit()
return await get_game(game_id, current_user, db)
async def get_available_games_for_participant(
db, participant: Participant, marathon_id: int
) -> tuple[list[Game], int]:
"""
Получить список игр, доступных для спина участника.
Возвращает кортеж (доступные игры, всего игр).
Логика исключения:
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
- challenges: игра исключается если участник выполнил ВСЕ челленджи
"""
from sqlalchemy.orm import selectinload
# Получаем все одобренные игры с челленджами
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value
)
)
all_games = list(result.scalars().all())
# Фильтруем игры с челленджами (для типа challenges)
# или игры с заполненными playthrough полями (для типа playthrough)
games_with_content = []
for game in all_games:
if game.game_type == GameType.PLAYTHROUGH.value:
# Для playthrough не нужны челленджи
if game.playthrough_points and game.playthrough_description:
games_with_content.append(game)
else:
# Для challenges нужны челленджи
if game.challenges:
games_with_content.append(game)
total_games = len(games_with_content)
if total_games == 0:
return [], 0
# Получаем завершённые/дропнутые assignments участника
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
playthrough_result = await db.execute(
select(Assignment.game_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == True,
Assignment.status.in_(finished_statuses)
)
)
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
# Для challenges: получаем challenge_id завершённых заданий
challenges_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == False,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_challenge_ids = set(challenges_result.scalars().all())
# Получаем изгнанные игры (is_active=True означает что игра изгнана)
exiled_result = await db.execute(
select(ExiledGame.game_id)
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
)
exiled_game_ids = set(exiled_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
# Исключаем изгнанные игры
if game.id in exiled_game_ids:
continue
if game.game_type == GameType.PLAYTHROUGH.value:
# Исключаем если игра уже завершена/дропнута
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# Для challenges: исключаем если все челленджи выполнены
game_challenge_ids = {c.id for c in game.challenges}
if not game_challenge_ids.issubset(completed_challenge_ids):
available_games.append(game)
return available_games, total_games
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
async def get_available_games_count(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить количество игр, доступных для спина.
Возвращает { available: X, total: Y } где:
- available: количество игр, которые могут выпасть
- total: общее количество игр в марафоне
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, total_games = await get_available_games_for_participant(
db, participant, marathon_id
)
return AvailableGamesCount(
available=len(available_games),
total=total_games
)
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
async def get_available_games(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить список игр, доступных для спина.
Возвращает только те игры, которые могут выпасть участнику:
- Для playthrough: исключаются игры которые уже завершены/дропнуты
- Для challenges: исключаются игры где все челленджи выполнены
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, _ = await get_available_games_for_participant(
db, participant, marathon_id
)
# Convert to response with challenges count
result = []
for game in available_games:
challenges_count = len(game.challenges) if game.challenges else 0
result.append(GameResponse(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
status=game.status,
proposed_by=None,
approved_by=None,
challenges_count=challenges_count,
created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
))
return result