Добавлен 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

@@ -19,6 +19,7 @@ from app.models.coin_transaction import CoinTransaction, CoinTransactionType
from app.models.consumable_usage import ConsumableUsage
from app.models.promo_code import PromoCode, PromoCodeRedemption
from app.models.widget_token import WidgetToken
from app.models.exiled_game import ExiledGame
__all__ = [
"User",
@@ -67,4 +68,5 @@ __all__ = [
"PromoCode",
"PromoCodeRedemption",
"WidgetToken",
"ExiledGame",
]

View File

@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
EVENT_END = "event_end"
SWAP = "swap"
GAME_CHOICE = "game_choice"
MODERATION = "moderation"
class Activity(Base):

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ExiledGame(Base):
"""Изгнанные игры участника - не будут выпадать при спине"""
__tablename__ = "exiled_games"
__table_args__ = (
UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"),
)
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(
Integer, ForeignKey("participants.id", ondelete="CASCADE"), index=True
)
game_id: Mapped[int] = mapped_column(
Integer, ForeignKey("games.id", ondelete="CASCADE")
)
assignment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("assignments.id", ondelete="SET NULL"), nullable=True
)
exiled_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
exiled_by: Mapped[str] = mapped_column(String(20)) # "user" | "organizer" | "admin"
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Soft-delete для истории
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
unexiled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unexiled_by: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant")
game: Mapped["Game"] = relationship("Game")
assignment: Mapped["Assignment"] = relationship("Assignment")

View File

@@ -28,6 +28,7 @@ class ItemRarity(str, Enum):
class ConsumableType(str, Enum):
SKIP = "skip"
SKIP_EXILE = "skip_exile" # Скип с изгнанием игры из пула
BOOST = "boost"
WILD_CARD = "wild_card"
LUCKY_DICE = "lucky_dice"