Добавлен 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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user