Добавлен 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>
This commit is contained in:
2026-01-10 23:01:23 +03:00
parent cf0df928b1
commit f78eacb1a5
24 changed files with 2194 additions and 14 deletions

View File

@@ -21,6 +21,7 @@ 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,
@@ -35,6 +36,8 @@ from app.schemas import (
MessageResponse,
UserPublic,
SetParticipantRole,
OrganizerSkipRequest,
ExiledGameResponse,
)
from app.services.telegram_notifier import telegram_notifier
@@ -1004,3 +1007,223 @@ async def resolve_marathon_dispute(
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")