Files
game-marathon/backend/app/api/v1/marathons.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

1230 lines
43 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 datetime import timedelta
import secrets
import string
from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import (
DbSession, CurrentUser,
require_participant, require_organizer, require_creator,
get_participant,
)
from app.core.config import settings
from app.core.security import decode_access_token
from app.services.storage import storage_service
# Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus, User,
ExiledGame,
)
from app.schemas import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
MarathonPublicInfo,
JoinMarathon,
ParticipantInfo,
ParticipantWithUser,
LeaderboardEntry,
MessageResponse,
UserPublic,
SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
)
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"])
# Public endpoint (no auth required)
@router.get("/by-code/{invite_code}", response_model=MarathonPublicInfo)
async def get_marathon_by_code(invite_code: str, db: DbSession):
"""Get public marathon info by invite code. No authentication required."""
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.options(selectinload(Marathon.creator))
.where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="Marathon not found")
marathon = row[0]
participants_count = row[1]
return MarathonPublicInfo(
id=marathon.id,
title=marathon.title,
description=marathon.description,
status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count,
creator_nickname=marathon.creator.nickname,
)
def generate_invite_code() -> str:
"""Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(
selectinload(Marathon.creator).selectinload(User.equipped_frame),
selectinload(Marathon.creator).selectinload(User.equipped_title),
selectinload(Marathon.creator).selectinload(User.equipped_name_color),
selectinload(Marathon.creator).selectinload(User.equipped_background),
)
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
return marathon
async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
return result.scalar_one_or_none()
@router.get("", response_model=list[MarathonListItem])
async def list_marathons(current_user: CurrentUser, db: DbSession):
"""Get all marathons where user is participant, creator, or public marathons"""
# Admin can see all marathons
if current_user.is_admin:
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
else:
# User can see: own marathons, participated marathons, and public marathons
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.where(
(Marathon.creator_id == current_user.id) |
(Participant.user_id == current_user.id) |
(Marathon.is_public == True)
)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
marathons = []
for row in result.all():
marathon = row[0]
marathons.append(MarathonListItem(
id=marathon.id,
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
))
return marathons
@router.post("", response_model=MarathonResponse)
async def create_marathon(
data: MarathonCreate,
current_user: CurrentUser,
db: DbSession,
):
# Strip timezone info for naive datetime columns
start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
end_date = start_date + timedelta(days=data.duration_days)
marathon = Marathon(
title=data.title,
description=data.description,
creator_id=current_user.id,
invite_code=generate_invite_code(),
is_public=data.is_public,
game_proposal_mode=data.game_proposal_mode,
start_date=start_date,
end_date=end_date,
)
db.add(marathon)
await db.flush()
# Auto-add creator as organizer participant
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.ORGANIZER.value, # Creator is organizer
)
db.add(participant)
await db.commit()
await db.refresh(marathon)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
creator=UserPublic.model_validate(current_user),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
games_count=0,
created_at=marathon.created_at,
my_participation=ParticipantInfo.model_validate(participant),
)
@router.get("/{marathon_id}", response_model=MarathonResponse)
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin/creator)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
# Count participants and approved games
participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
)
games_count = await db.scalar(
select(func.count()).select_from(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
# Get user's participation
participation = await get_participation(db, current_user.id, marathon_id)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
creator=UserPublic.model_validate(marathon.creator),
status=marathon.status,
invite_code=marathon.invite_code,
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
games_count=games_count,
created_at=marathon.created_at,
my_participation=ParticipantInfo.model_validate(participation) if participation else None,
)
@router.patch("/{marathon_id}", response_model=MarathonResponse)
async def update_marathon(
marathon_id: int,
data: MarathonUpdate,
current_user: CurrentUser,
db: DbSession,
):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
if data.title is not None:
marathon.title = data.title
if data.description is not None:
marathon.description = data.description
if data.start_date is not None:
# Strip timezone info for naive datetime columns
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
if data.is_public is not None:
marathon.is_public = data.is_public
if data.game_proposal_mode is not None:
marathon.game_proposal_mode = data.game_proposal_mode
if data.auto_events_enabled is not None:
marathon.auto_events_enabled = data.auto_events_enabled
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Only creator or admin can delete
await require_creator(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
await db.delete(marathon)
await db.commit()
return MessageResponse(message="Marathon deleted")
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are approved games
games_result = await db.execute(
select(Game).where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
)
)
approved_games = games_result.scalars().all()
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved challenge-based games have at least one challenge
# Playthrough games don't need challenges
games_without_challenges = []
for game in approved_games:
if game.is_playthrough:
continue # Игры типа "Прохождение" не требуют челленджей
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
marathon.status = MarathonStatus.ACTIVE.value
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.START_MARATHON.value,
data={"title": marathon.title},
)
db.add(activity)
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
from app.services.coins import coins_service
# Require organizer role
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
marathon.status = MarathonStatus.FINISHED.value
# Award coins for top 3 places (only in certified marathons)
if marathon.is_certified:
# Get top 3 participants by total_points
top_result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
.limit(3)
)
top_participants = top_result.scalars().all()
for place, participant in enumerate(top_participants, start=1):
if participant.total_points > 0: # Only award if they have points
await coins_service.award_marathon_place(
db, participant.user, marathon, place
)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.FINISH_MARATHON.value,
data={"title": marathon.title},
)
db.add(activity)
await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db)
@router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute(
select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Invalid invite code")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon has already finished")
# Check if already participant
existing = await get_participation(db, current_user.id, marathon.id)
if existing:
raise HTTPException(status_code=400, detail="Already joined this marathon")
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.PARTICIPANT.value, # Regular participant
)
db.add(participant)
# Log activity
activity = Activity(
marathon_id=marathon.id,
user_id=current_user.id,
type=ActivityType.JOIN.value,
data={"nickname": current_user.nickname},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon.id, current_user, db)
@router.post("/{marathon_id}/join", response_model=MarathonResponse)
async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Join a public marathon without invite code"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.is_public:
raise HTTPException(status_code=403, detail="This marathon is private. Use invite code to join.")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon has already finished")
# Check if already participant
existing = await get_participation(db, current_user.id, marathon.id)
if existing:
raise HTTPException(status_code=400, detail="Already joined this marathon")
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
role=ParticipantRole.PARTICIPANT.value,
)
db.add(participant)
# Log activity
activity = Activity(
marathon_id=marathon.id,
user_id=current_user.id,
type=ActivityType.JOIN.value,
data={"nickname": current_user.nickname},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon.id, current_user, db)
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
result = await db.execute(
select(Participant)
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.joined_at)
)
participants = result.scalars().all()
return [
ParticipantWithUser(
id=p.id,
role=p.role,
total_points=p.total_points,
current_streak=p.current_streak,
drop_count=p.drop_count,
joined_at=p.joined_at,
user=UserPublic.model_validate(p.user),
)
for p in participants
]
@router.patch("/{marathon_id}/participants/{user_id}/role", response_model=ParticipantWithUser)
async def set_participant_role(
marathon_id: int,
user_id: int,
data: SetParticipantRole,
current_user: CurrentUser,
db: DbSession,
):
"""Set participant's role (only creator can do this)"""
# Only creator can change roles
marathon = await require_creator(db, current_user, marathon_id)
# Cannot change creator's role
if user_id == marathon.creator_id:
raise HTTPException(status_code=400, detail="Cannot change creator's role")
# Get participant
result = await db.execute(
select(Participant)
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
participant.role = data.role
await db.commit()
await db.refresh(participant)
return ParticipantWithUser(
id=participant.id,
role=participant.role,
total_points=participant.total_points,
current_streak=participant.current_streak,
drop_count=participant.drop_count,
joined_at=participant.joined_at,
user=UserPublic.model_validate(participant.user),
)
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(
marathon_id: int,
db: DbSession,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
):
"""
Get marathon leaderboard.
Public marathons: no auth required.
Private marathons: requires auth + participation check.
"""
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require authentication and participation
if not marathon.is_public:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private marathon leaderboard",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
participant = await get_participant(db, user_id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
result = await db.execute(
select(Participant)
.options(
selectinload(Participant.user).selectinload(User.equipped_frame),
selectinload(Participant.user).selectinload(User.equipped_title),
selectinload(Participant.user).selectinload(User.equipped_name_color),
selectinload(Participant.user).selectinload(User.equipped_background),
)
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
)
participants = result.scalars().all()
leaderboard = []
for rank, p in enumerate(participants, 1):
# Count completed and dropped assignments
completed = await db.scalar(
select(func.count()).select_from(Assignment).where(
Assignment.participant_id == p.id,
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
dropped = await db.scalar(
select(func.count()).select_from(Assignment).where(
Assignment.participant_id == p.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
)
leaderboard.append(LeaderboardEntry(
rank=rank,
user=UserPublic.model_validate(p.user),
total_points=p.total_points,
current_streak=p.current_streak,
completed_count=completed,
dropped_count=dropped,
))
return leaderboard
@router.get("/{marathon_id}/cover")
async def get_marathon_cover(marathon_id: int, db: DbSession):
"""Get marathon cover image"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.cover_path:
raise HTTPException(status_code=404, detail="Marathon has no cover")
file_data = await storage_service.get_file(marathon.cover_path, "covers")
if not file_data:
raise HTTPException(status_code=404, detail="Cover not found in storage")
content, content_type = file_data
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=3600",
}
)
@router.post("/{marathon_id}/cover", response_model=MarathonResponse)
async def upload_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
"""Upload marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
# Validate file
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image",
)
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
# Get file extension
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Delete old cover if exists
if marathon.cover_path:
await storage_service.delete_file(marathon.cover_path)
# Upload file
filename = storage_service.generate_filename(marathon_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update marathon with cover path and URL
marathon.cover_path = file_path
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
async def delete_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
if not marathon.cover_path:
raise HTTPException(status_code=400, detail="Marathon has no cover")
# Delete file from storage
await storage_service.delete_file(marathon.cover_path)
marathon.cover_path = None
marathon.cover_url = None
await db.commit()
return await get_marathon(marathon_id, current_user, db)
# ============ Marathon Disputes (for organizers) ============
from pydantic import BaseModel, Field
from datetime import datetime
class MarathonDisputeResponse(BaseModel):
id: int
assignment_id: int | None
bonus_assignment_id: int | None
challenge_title: str
participant_nickname: str
raised_by_nickname: str
reason: str
status: str
votes_valid: int
votes_invalid: int
created_at: str
expires_at: str
class Config:
from_attributes = True
class ResolveDisputeRequest(BaseModel):
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
async def list_marathon_disputes(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
status_filter: str = "open",
):
"""List disputes in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
from datetime import timedelta
DISPUTE_WINDOW_HOURS = 24
# Get all assignments in this marathon (through games)
games_result = await db.execute(
select(Game.id).where(Game.marathon_id == marathon_id)
)
game_ids = [g[0] for g in games_result.all()]
if not game_ids:
return []
# Get disputes for assignments in these games
# Using selectinload for eager loading - no explicit joins needed
query = (
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.order_by(Dispute.created_at.desc())
)
if status_filter == "open":
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
result = await db.execute(query)
all_disputes = result.scalars().unique().all()
# Filter disputes that belong to this marathon's games
response = []
for dispute in all_disputes:
# Check if dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if not bonus or not bonus.main_assignment:
continue
if bonus.main_assignment.game_id not in game_ids:
continue
participant = bonus.main_assignment.participant
challenge_title = f"Бонус: {bonus.challenge.title}"
else:
assignment = dispute.assignment
if not assignment:
continue
if assignment.is_playthrough:
if assignment.game_id not in game_ids:
continue
challenge_title = f"Прохождение: {assignment.game.title}"
else:
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
continue
challenge_title = assignment.challenge.title
participant = assignment.participant
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Calculate expiry
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
response.append(MarathonDisputeResponse(
id=dispute.id,
assignment_id=dispute.assignment_id,
bonus_assignment_id=dispute.bonus_assignment_id,
challenge_title=challenge_title,
participant_nickname=participant.user.nickname,
raised_by_nickname=dispute.raised_by.nickname,
reason=dispute.reason,
status=dispute.status,
votes_valid=votes_valid,
votes_invalid=votes_invalid,
created_at=dispute.created_at.isoformat(),
expires_at=expires_at.isoformat(),
))
return response
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
async def resolve_marathon_dispute(
marathon_id: int,
dispute_id: int,
data: ResolveDisputeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Manually resolve a dispute in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
# Get dispute
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Verify dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if bonus.main_assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
assignment = dispute.assignment
if assignment.is_playthrough:
if assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
if assignment.challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Determine result
if data.is_valid:
result_status = DisputeStatus.RESOLVED_VALID.value
else:
result_status = DisputeStatus.RESOLVED_INVALID.value
# Handle invalid proof
if dispute.bonus_assignment_id:
# Reset bonus assignment
bonus = dispute.bonus_assignment
main_assignment = bonus.main_assignment
participant = main_assignment.participant
# Only subtract points if main playthrough was already completed
# (bonus points are added only when main playthrough is completed)
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
bonus.status = BonusAssignmentStatus.PENDING.value
bonus.proof_path = None
bonus.proof_url = None
bonus.proof_comment = None
bonus.points_earned = 0
bonus.completed_at = None
else:
# Reset main assignment
assignment = dispute.assignment
participant = assignment.participant
# Subtract points
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid
participant.current_streak = 0
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
for ba in bonus_result.scalars().all():
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send notification
if dispute.bonus_assignment_id:
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
elif dispute.assignment.is_playthrough:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
else:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = dispute.assignment.challenge.title
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant_user_id,
marathon_title=marathon.title,
challenge_title=challenge_title,
is_valid=data.is_valid
)
return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
)
# ============= Moderation Endpoints =============
@router.post("/{marathon_id}/participants/{user_id}/skip-assignment", response_model=MessageResponse)
async def organizer_skip_assignment(
marathon_id: int,
user_id: int,
data: OrganizerSkipRequest,
current_user: CurrentUser,
db: DbSession,
):
"""
Organizer skips a participant's current assignment.
- No penalty for participant
- Streak is preserved
- Optionally exile the game from participant's pool
"""
await require_organizer(db, current_user, marathon_id)
# Get marathon
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")
# Get target participant
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get active assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=400, detail="Participant has no active assignment")
# Get game info
if assignment.is_playthrough:
game = assignment.game
game_id = game.id
game_title = game.title
else:
game = assignment.challenge.game
game_id = game.id
game_title = game.title
# Skip the assignment (no penalty)
from datetime import datetime
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Note: We do NOT reset streak or increment drop_count
# Exile the game if requested
if data.exile:
# Check if already exiled
existing = await db.execute(
select(ExiledGame).where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
if not existing.scalar_one_or_none():
exiled = ExiledGame(
participant_id=participant.id,
game_id=game_id,
assignment_id=assignment.id,
exiled_by="organizer",
reason=data.reason,
)
db.add(exiled)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.MODERATION.value,
data={
"action": "skip_assignment",
"target_user_id": user_id,
"target_nickname": participant.user.nickname,
"assignment_id": assignment.id,
"game_id": game_id,
"game_title": game_title,
"exile": data.exile,
"reason": data.reason,
}
)
db.add(activity)
await db.commit()
# Send notification
await telegram_notifier.notify_assignment_skipped_by_moderator(
db,
user=participant.user,
marathon_title=marathon.title,
game_title=game_title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
exile_msg = " and exiled from pool" if data.exile else ""
return MessageResponse(message=f"Assignment skipped{exile_msg}")
@router.get("/{marathon_id}/participants/{user_id}/exiled-games", response_model=list[ExiledGameResponse])
async def get_participant_exiled_games(
marathon_id: int,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get list of exiled games for a participant (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled games
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.is_active == True,
)
.order_by(ExiledGame.exiled_at.desc())
)
exiled_games = result.scalars().all()
return [
ExiledGameResponse(
id=eg.id,
game_id=eg.game_id,
game_title=eg.game.title,
exiled_at=eg.exiled_at,
exiled_by=eg.exiled_by,
reason=eg.reason,
)
for eg in exiled_games
]
@router.post("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore", response_model=MessageResponse)
async def restore_exiled_game(
marathon_id: int,
user_id: int,
game_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Restore an exiled game back to participant's pool (organizers only)"""
await require_organizer(db, current_user, marathon_id)
# Get participant
result = await db.execute(
select(Participant).where(
Participant.marathon_id == marathon_id,
Participant.user_id == user_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
# Get exiled game
result = await db.execute(
select(ExiledGame)
.options(selectinload(ExiledGame.game))
.where(
ExiledGame.participant_id == participant.id,
ExiledGame.game_id == game_id,
ExiledGame.is_active == True,
)
)
exiled_game = result.scalar_one_or_none()
if not exiled_game:
raise HTTPException(status_code=404, detail="Exiled game not found")
# Restore (soft-delete)
from datetime import datetime
exiled_game.is_active = False
exiled_game.unexiled_at = datetime.utcnow()
exiled_game.unexiled_by = "organizer"
await db.commit()
return MessageResponse(message=f"Game '{exiled_game.game.title}' restored to pool")