- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
591 lines
22 KiB
Python
591 lines
22 KiB
Python
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
|
||
)
|
||
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(Game.approved_by),
|
||
)
|
||
.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(Game.approved_by),
|
||
)
|
||
.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(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()]
|
||
|
||
|
||
@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())
|
||
|
||
# Фильтруем доступные игры
|
||
available_games = []
|
||
for game in games_with_content:
|
||
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
|