From f78eacb1a56573a9c905427c0b1cc3ee13bb6c7b Mon Sep 17 00:00:00 2001 From: "mamonov.ep" Date: Sat, 10 Jan 2026 23:01:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20Skip=20with=20Exile,=20=D0=BC=D0=BE=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=B0=D1=80=D0=B0=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BC=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../alembic/versions/029_add_widget_tokens.py | 10 + .../alembic/versions/030_add_exiled_games.py | 65 ++ backend/app/api/v1/games.py | 17 +- backend/app/api/v1/marathons.py | 223 +++++ backend/app/api/v1/shop.py | 155 +++- backend/app/models/__init__.py | 2 + backend/app/models/activity.py | 1 + backend/app/models/exiled_game.py | 37 + backend/app/models/shop.py | 1 + backend/app/schemas/__init__.py | 6 + backend/app/schemas/marathon.py | 20 + backend/app/schemas/shop.py | 10 + backend/app/services/consumables.py | 107 ++- backend/app/services/telegram_notifier.py | 51 ++ docs/tz-skip-exile-moderation.md | 789 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/api/marathons.ts | 34 +- frontend/src/api/shop.ts | 27 + frontend/src/pages/LeaderboardPage.tsx | 171 +++- frontend/src/pages/PlayPage.tsx | 51 ++ .../src/pages/admin/AdminGrantItemPage.tsx | 404 +++++++++ frontend/src/pages/admin/AdminUsersPage.tsx | 12 +- frontend/src/pages/admin/index.ts | 1 + frontend/src/types/index.ts | 12 +- 24 files changed, 2194 insertions(+), 14 deletions(-) create mode 100644 backend/alembic/versions/030_add_exiled_games.py create mode 100644 backend/app/models/exiled_game.py create mode 100644 docs/tz-skip-exile-moderation.md create mode 100644 frontend/src/pages/admin/AdminGrantItemPage.tsx diff --git a/backend/alembic/versions/029_add_widget_tokens.py b/backend/alembic/versions/029_add_widget_tokens.py index 46ccde4..de16a0a 100644 --- a/backend/alembic/versions/029_add_widget_tokens.py +++ b/backend/alembic/versions/029_add_widget_tokens.py @@ -5,6 +5,7 @@ Revises: 028 Create Date: 2025-01-09 """ from alembic import op +from sqlalchemy import inspect import sqlalchemy as sa @@ -14,7 +15,16 @@ branch_labels = None depends_on = None +def table_exists(table_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() + + def upgrade(): + if table_exists('widget_tokens'): + return + op.create_table( 'widget_tokens', sa.Column('id', sa.Integer(), nullable=False), diff --git a/backend/alembic/versions/030_add_exiled_games.py b/backend/alembic/versions/030_add_exiled_games.py new file mode 100644 index 0000000..c17dbe3 --- /dev/null +++ b/backend/alembic/versions/030_add_exiled_games.py @@ -0,0 +1,65 @@ +"""Add exiled games and skip_exile consumable + +Revision ID: 030 +Revises: 029 +Create Date: 2025-01-10 +""" +from alembic import op +from sqlalchemy import inspect +import sqlalchemy as sa + + +revision = '030_add_exiled_games' +down_revision = '029_add_widget_tokens' +branch_labels = None +depends_on = None + + +def table_exists(table_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return table_name in inspector.get_table_names() + + +def upgrade(): + # Create exiled_games table if not exists + if not table_exists('exiled_games'): + op.create_table( + 'exiled_games', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('game_id', sa.Integer(), nullable=False), + sa.Column('assignment_id', sa.Integer(), nullable=True), + sa.Column('exiled_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('exiled_by', sa.String(20), nullable=False), + sa.Column('reason', sa.String(500), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('unexiled_at', sa.DateTime(), nullable=True), + sa.Column('unexiled_by', sa.String(20), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['participant_id'], ['participants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['game_id'], ['games.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='SET NULL'), + sa.UniqueConstraint('participant_id', 'game_id', name='unique_participant_game_exile'), + ) + op.create_index('ix_exiled_games_participant_id', 'exiled_games', ['participant_id']) + op.create_index('ix_exiled_games_active', 'exiled_games', ['participant_id', 'is_active']) + + # Add skip_exile consumable to shop if not exists + op.execute(""" + INSERT INTO shop_items (item_type, code, name, description, price, rarity, asset_data, is_active, created_at) + SELECT 'consumable', 'skip_exile', 'Скип с изгнанием', + 'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула и больше не выпадет.', + 150, 'rare', '{"effect": "skip_exile", "icon": "x-circle"}', true, NOW() + WHERE NOT EXISTS (SELECT 1 FROM shop_items WHERE code = 'skip_exile') + """) + + +def downgrade(): + # Remove skip_exile from shop + op.execute("DELETE FROM shop_items WHERE code = 'skip_exile'") + + # Drop exiled_games table + op.drop_index('ix_exiled_games_active', table_name='exiled_games') + op.drop_index('ix_exiled_games_participant_id', table_name='exiled_games') + op.drop_table('exiled_games') diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index 7894238..4cde213 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -9,7 +9,8 @@ from app.api.deps import ( from app.core.config import settings from app.models import ( Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType, - Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User + Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant, User, + ExiledGame ) from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas.assignment import AvailableGamesCount @@ -519,9 +520,23 @@ async def get_available_games_for_participant( ) completed_challenge_ids = set(challenges_result.scalars().all()) + # Получаем изгнанные игры (is_active=True означает что игра изгнана) + exiled_result = await db.execute( + select(ExiledGame.game_id) + .where( + ExiledGame.participant_id == participant.id, + ExiledGame.is_active == True, + ) + ) + exiled_game_ids = set(exiled_result.scalars().all()) + # Фильтруем доступные игры available_games = [] for game in games_with_content: + # Исключаем изгнанные игры + if game.id in exiled_game_ids: + continue + if game.game_type == GameType.PLAYTHROUGH.value: # Исключаем если игра уже завершена/дропнута if game.id not in finished_playthrough_game_ids: diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index c8b9e34..c2ef81a 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -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") diff --git a/backend/app/api/v1/shop.py b/backend/app/api/v1/shop.py index 98e3a68..f9c0fc2 100644 --- a/backend/app/api/v1/shop.py +++ b/backend/app/api/v1/shop.py @@ -20,11 +20,13 @@ from app.schemas import ( CoinTransactionResponse, CoinsBalanceResponse, AdminCoinsRequest, CertificationRequestSchema, CertificationReviewRequest, CertificationStatusResponse, ConsumablesStatusResponse, MessageResponse, SwapCandidate, + AdminGrantItemRequest, ) from app.schemas.user import UserPublic from app.services.shop import shop_service from app.services.coins import coins_service from app.services.consumables import consumables_service +from app.services.telegram_notifier import telegram_notifier router = APIRouter(prefix="/shop", tags=["shop"]) @@ -184,7 +186,7 @@ async def use_consumable( # For some consumables, we need the assignment assignment = None - if data.item_code in ["skip", "wild_card", "copycat"]: + if data.item_code in ["skip", "skip_exile", "wild_card", "copycat"]: if not data.assignment_id: raise HTTPException(status_code=400, detail=f"assignment_id is required for {data.item_code}") @@ -213,6 +215,9 @@ async def use_consumable( if data.item_code == "skip": effect = await consumables_service.use_skip(db, current_user, participant, marathon, assignment) effect_description = "Assignment skipped without penalty" + elif data.item_code == "skip_exile": + effect = await consumables_service.use_skip_exile(db, current_user, participant, marathon, assignment) + effect_description = "Assignment skipped, game exiled from pool" elif data.item_code == "boost": effect = await consumables_service.use_boost(db, current_user, participant, marathon) effect_description = f"Boost x{effect['multiplier']} activated for current assignment" @@ -269,6 +274,7 @@ async def get_consumables_status( # Get inventory counts for all consumables skips_available = await consumables_service.get_consumable_count(db, current_user.id, "skip") + skip_exiles_available = await consumables_service.get_consumable_count(db, current_user.id, "skip_exile") boosts_available = await consumables_service.get_consumable_count(db, current_user.id, "boost") wild_cards_available = await consumables_service.get_consumable_count(db, current_user.id, "wild_card") lucky_dice_available = await consumables_service.get_consumable_count(db, current_user.id, "lucky_dice") @@ -282,6 +288,7 @@ async def get_consumables_status( return ConsumablesStatusResponse( skips_available=skips_available, + skip_exiles_available=skip_exiles_available, skips_used=participant.skips_used, skips_remaining=skips_remaining, boosts_available=boosts_available, @@ -749,3 +756,149 @@ async def admin_review_certification( certified_by_nickname=current_user.nickname if data.approve else None, rejection_reason=marathon.certification_rejection_reason, ) + + +# === Admin Item Granting === + +@router.post("/admin/users/{user_id}/items/grant", response_model=MessageResponse) +async def admin_grant_item( + user_id: int, + data: AdminGrantItemRequest, + current_user: CurrentUser, + db: DbSession, +): + """Grant an item to a user (admin only)""" + require_admin_with_2fa(current_user) + + # Get target user + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get item + item = await shop_service.get_item_by_id(db, data.item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + # Check if user already has this item in inventory + result = await db.execute( + select(UserInventory).where( + UserInventory.user_id == user_id, + UserInventory.item_id == data.item_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + # Add to quantity + existing.quantity += data.quantity + else: + # Create new inventory item + inventory_item = UserInventory( + user_id=user_id, + item_id=data.item_id, + quantity=data.quantity, + ) + db.add(inventory_item) + + # Log the action (using coin transaction as audit log) + transaction = CoinTransaction( + user_id=user_id, + amount=0, + transaction_type="admin_grant_item", + description=f"Admin granted {item.name} x{data.quantity}: {data.reason}", + reference_type="admin_action", + reference_id=current_user.id, + ) + db.add(transaction) + + await db.commit() + + # Send Telegram notification + await telegram_notifier.notify_item_granted( + user=user, + item_name=item.name, + quantity=data.quantity, + reason=data.reason, + admin_nickname=current_user.nickname, + ) + + return MessageResponse(message=f"Granted {item.name} x{data.quantity} to {user.nickname}") + + +@router.get("/admin/users/{user_id}/inventory", response_model=list[InventoryItemResponse]) +async def admin_get_user_inventory( + user_id: int, + current_user: CurrentUser, + db: DbSession, + item_type: str | None = None, +): + """Get a user's inventory (admin only)""" + require_admin_with_2fa(current_user) + + # Check user exists + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + inventory = await shop_service.get_user_inventory(db, user_id, item_type) + return [InventoryItemResponse.model_validate(inv) for inv in inventory] + + +@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse) +async def admin_remove_inventory_item( + user_id: int, + inventory_id: int, + current_user: CurrentUser, + db: DbSession, + quantity: int = 1, +): + """Remove an item from user's inventory (admin only)""" + require_admin_with_2fa(current_user) + + # Check user exists + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get inventory item + result = await db.execute( + select(UserInventory) + .options(selectinload(UserInventory.item)) + .where( + UserInventory.id == inventory_id, + UserInventory.user_id == user_id, + ) + ) + inv = result.scalar_one_or_none() + if not inv: + raise HTTPException(status_code=404, detail="Inventory item not found") + + item_name = inv.item.name + + if quantity >= inv.quantity: + # Remove entirely + await db.delete(inv) + removed_qty = inv.quantity + else: + # Reduce quantity + inv.quantity -= quantity + removed_qty = quantity + + # Log the action + transaction = CoinTransaction( + user_id=user_id, + amount=0, + transaction_type="admin_remove_item", + description=f"Admin removed {item_name} x{removed_qty}", + reference_type="admin_action", + reference_id=current_user.id, + ) + db.add(transaction) + + await db.commit() + + return MessageResponse(message=f"Removed {item_name} x{removed_qty} from {user.nickname}") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8b4b8a5..4da8dba 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py index de7613b..d6c968f 100644 --- a/backend/app/models/activity.py +++ b/backend/app/models/activity.py @@ -20,6 +20,7 @@ class ActivityType(str, Enum): EVENT_END = "event_end" SWAP = "swap" GAME_CHOICE = "game_choice" + MODERATION = "moderation" class Activity(Base): diff --git a/backend/app/models/exiled_game.py b/backend/app/models/exiled_game.py new file mode 100644 index 0000000..d25b872 --- /dev/null +++ b/backend/app/models/exiled_game.py @@ -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") diff --git a/backend/app/models/shop.py b/backend/app/models/shop.py index d83fba3..03448a2 100644 --- a/backend/app/models/shop.py +++ b/backend/app/models/shop.py @@ -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" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 66bcbdb..b6466f0 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -23,6 +23,8 @@ from app.schemas.marathon import ( JoinMarathon, LeaderboardEntry, SetParticipantRole, + OrganizerSkipRequest, + ExiledGameResponse, ) from app.schemas.game import ( GameCreate, @@ -124,6 +126,7 @@ from app.schemas.shop import ( CertificationReviewRequest, CertificationStatusResponse, ConsumablesStatusResponse, + AdminGrantItemRequest, ) from app.schemas.promo_code import ( PromoCodeCreate, @@ -170,6 +173,8 @@ __all__ = [ "JoinMarathon", "LeaderboardEntry", "SetParticipantRole", + "OrganizerSkipRequest", + "ExiledGameResponse", # Game "GameCreate", "GameUpdate", @@ -262,6 +267,7 @@ __all__ = [ "CertificationReviewRequest", "CertificationStatusResponse", "ConsumablesStatusResponse", + "AdminGrantItemRequest", # Promo "PromoCodeCreate", "PromoCodeUpdate", diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index b32dea3..cb51d96 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -128,3 +128,23 @@ class LeaderboardEntry(BaseModel): current_streak: int completed_count: int dropped_count: int + + +# Moderation schemas +class OrganizerSkipRequest(BaseModel): + """Request to skip a participant's assignment by organizer""" + exile: bool = False # If true, also exile the game from participant's pool + reason: str | None = None + + +class ExiledGameResponse(BaseModel): + """Exiled game info""" + id: int + game_id: int + game_title: str + exiled_at: datetime + exiled_by: str # "user" | "organizer" | "admin" + reason: str | None + + class Config: + from_attributes = True diff --git a/backend/app/schemas/shop.py b/backend/app/schemas/shop.py index cbf757d..da52968 100644 --- a/backend/app/schemas/shop.py +++ b/backend/app/schemas/shop.py @@ -192,6 +192,7 @@ class CertificationStatusResponse(BaseModel): class ConsumablesStatusResponse(BaseModel): """Schema for participant's consumables status in a marathon""" skips_available: int # From inventory + skip_exiles_available: int = 0 # From inventory (skip with exile) skips_used: int # In this marathon skips_remaining: int | None # Based on marathon limit boosts_available: int # From inventory @@ -204,3 +205,12 @@ class ConsumablesStatusResponse(BaseModel): copycats_available: int # From inventory undos_available: int # From inventory can_undo: bool # Has drop data to undo + + +# === Admin Item Granting === + +class AdminGrantItemRequest(BaseModel): + """Schema for admin granting item to user""" + item_id: int + quantity: int = Field(default=1, ge=1, le=100) + reason: str = Field(..., min_length=1, max_length=500) diff --git a/backend/app/services/consumables.py b/backend/app/services/consumables.py index 05ec983..442c5f0 100644 --- a/backend/app/services/consumables.py +++ b/backend/app/services/consumables.py @@ -3,6 +3,7 @@ Consumables Service - handles consumable items usage Consumables: - skip: Skip current assignment without penalty +- skip_exile: Skip + permanently exile game from pool - boost: x1.5 multiplier for current assignment - wild_card: Choose a game, get random challenge from it - lucky_dice: Random multiplier (0.5, 1.0, 1.5, 2.0, 2.5, 3.0) @@ -19,7 +20,7 @@ from sqlalchemy.orm import selectinload from app.models import ( User, Participant, Marathon, Assignment, AssignmentStatus, ShopItem, UserInventory, ConsumableUsage, ConsumableType, Game, Challenge, - BonusAssignment + BonusAssignment, ExiledGame ) @@ -98,6 +99,110 @@ class ConsumablesService: "streak_preserved": True, } + async def use_skip_exile( + self, + db: AsyncSession, + user: User, + participant: Participant, + marathon: Marathon, + assignment: Assignment, + ) -> dict: + """ + Use Skip with Exile - skip assignment AND permanently exile game from pool. + + - No streak loss + - No drop penalty + - Game is permanently excluded from participant's pool + + Returns: dict with result info + + Raises: + HTTPException: If skips not allowed or limit reached + """ + # Check marathon settings (same as regular skip) + if not marathon.allow_skips: + raise HTTPException(status_code=400, detail="Skips are not allowed in this marathon") + + if marathon.max_skips_per_participant is not None: + if participant.skips_used >= marathon.max_skips_per_participant: + raise HTTPException( + status_code=400, + detail=f"Skip limit reached ({marathon.max_skips_per_participant} per participant)" + ) + + # Check assignment is active + if assignment.status != AssignmentStatus.ACTIVE.value: + raise HTTPException(status_code=400, detail="Can only skip active assignments") + + # Get game_id (different for playthrough vs challenges) + if assignment.is_playthrough: + game_id = assignment.game_id + else: + # Need to load challenge to get game_id + if assignment.challenge: + game_id = assignment.challenge.game_id + else: + # Load challenge if not already loaded + result = await db.execute( + select(Challenge).where(Challenge.id == assignment.challenge_id) + ) + challenge = result.scalar_one() + game_id = challenge.game_id + + # Check if game is already exiled + existing = await db.execute( + select(ExiledGame).where( + ExiledGame.participant_id == participant.id, + ExiledGame.game_id == game_id, + ExiledGame.is_active == True, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Game is already exiled") + + # Consume skip_exile from inventory + item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value) + + # Mark assignment as dropped (without penalty) + assignment.status = AssignmentStatus.DROPPED.value + assignment.completed_at = datetime.utcnow() + + # Track skip usage + participant.skips_used += 1 + + # Add game to exiled list + exiled = ExiledGame( + participant_id=participant.id, + game_id=game_id, + assignment_id=assignment.id, + exiled_by="user", + ) + db.add(exiled) + + # Log usage + usage = ConsumableUsage( + user_id=user.id, + item_id=item.id, + marathon_id=marathon.id, + assignment_id=assignment.id, + effect_data={ + "type": "skip_exile", + "skipped_without_penalty": True, + "game_exiled": True, + "game_id": game_id, + }, + ) + db.add(usage) + + return { + "success": True, + "skipped": True, + "exiled": True, + "game_id": game_id, + "penalty": 0, + "streak_preserved": True, + } + async def use_boost( self, db: AsyncSession, diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py index f98de46..5c03329 100644 --- a/backend/app/services/telegram_notifier.py +++ b/backend/app/services/telegram_notifier.py @@ -608,6 +608,57 @@ class TelegramNotifier: reply_markup=reply_markup ) + async def notify_assignment_skipped_by_moderator( + self, + db, + user, + marathon_title: str, + game_title: str, + exiled: bool, + reason: str | None, + moderator_nickname: str, + ) -> bool: + """Notify participant that their assignment was skipped by organizer""" + if not user.telegram_id or not user.notify_moderation: + return False + + exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else "" + reason_text = f"\n📝 Причина: {reason}" if reason else "" + + message = ( + f"⏭️ Задание пропущено\n\n" + f"Марафон: {marathon_title}\n" + f"Игра: {game_title}\n" + f"Организатор: {moderator_nickname}" + f"{exile_text}" + f"{reason_text}\n\n" + f"Вы можете крутить колесо заново." + ) + + return await self.send_message(user.telegram_id, message) + + async def notify_item_granted( + self, + user, + item_name: str, + quantity: int, + reason: str, + admin_nickname: str, + ) -> bool: + """Notify user that they received an item from admin""" + if not user.telegram_id: + return False + + message = ( + f"🎁 Вы получили подарок!\n\n" + f"Предмет: {item_name}\n" + f"Количество: {quantity}\n" + f"От: {admin_nickname}\n" + f"Причина: {reason}" + ) + + return await self.send_message(user.telegram_id, message) + # Global instance telegram_notifier = TelegramNotifier() diff --git a/docs/tz-skip-exile-moderation.md b/docs/tz-skip-exile-moderation.md new file mode 100644 index 0000000..9bcbf2e --- /dev/null +++ b/docs/tz-skip-exile-moderation.md @@ -0,0 +1,789 @@ +# ТЗ: Скип с изгнанием, модерация и выдача предметов + +## Обзор + +Три связанные фичи: +1. **Скип с изгнанием** — новый консамбл, который скипает задание И навсегда исключает игру из пула участника +2. **Модерация марафона** — организаторы могут скипать задания у участников (обычный скип / скип с изгнанием) +3. **Выдача предметов админами** — UI для системных администраторов для выдачи предметов пользователям + +--- + +## 1. Скип с изгнанием (SKIP_EXILE) + +### 1.1 Концепция + +| Тип скипа | Штраф | Стрик | Игра может выпасть снова | +|-----------|-------|-------|--------------------------| +| Обычный DROP | Да (прогрессивный) | Сбрасывается | Да (для challenges) / Нет (для playthrough) | +| SKIP (консамбл) | Нет | Сохраняется | Да (для challenges) / Нет (для playthrough) | +| **SKIP_EXILE** | Нет | Сохраняется | **Нет** | + +### 1.2 Backend + +#### Новая модель: ExiledGame +```python +# backend/app/models/exiled_game.py +class ExiledGame(Base): + __tablename__ = "exiled_games" + __table_args__ = ( + UniqueConstraint("participant_id", "game_id", name="unique_participant_game_exile"), + ) + + id: int (PK) + participant_id: int (FK -> participants.id, ondelete=CASCADE) + game_id: int (FK -> games.id, ondelete=CASCADE) + assignment_id: int | None (FK -> assignments.id) # Какое задание было при изгнании + exiled_at: datetime + exiled_by: str # "user" | "organizer" | "admin" + reason: str | None # Опциональная причина + + # История восстановления (soft-delete pattern) + is_active: bool = True # False = игра возвращена в пул + unexiled_at: datetime | None + unexiled_by: str | None # "organizer" | "admin" +``` + +> **Примечание**: При восстановлении игры запись НЕ удаляется, а помечается `is_active=False`. +> Это сохраняет историю изгнаний для аналитики и разрешения споров. + +#### Новый ConsumableType +```python +# backend/app/models/shop.py +class ConsumableType(str, Enum): + SKIP = "skip" + SKIP_EXILE = "skip_exile" # NEW + BOOST = "boost" + WILD_CARD = "wild_card" + LUCKY_DICE = "lucky_dice" + COPYCAT = "copycat" + UNDO = "undo" +``` + +#### Создание предмета в магазине +```python +# Предмет добавляется через админку или миграцию +ShopItem( + item_type="consumable", + code="skip_exile", + name="Скип с изгнанием", + description="Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.", + price=150, # Дороже обычного скипа (50) + rarity="rare", +) +``` + +#### Сервис: use_skip_exile +```python +# backend/app/services/consumables.py + +async def use_skip_exile( + self, + db: AsyncSession, + user: User, + participant: Participant, + marathon: Marathon, + assignment: Assignment, +) -> dict: + """ + Skip assignment AND exile the game permanently. + + - No streak loss + - No drop penalty + - Game is permanently excluded from participant's pool + """ + # Проверки как у обычного skip + if not marathon.allow_skips: + raise HTTPException(400, "Skips not allowed") + + if marathon.max_skips_per_participant is not None: + if participant.skips_used >= marathon.max_skips_per_participant: + raise HTTPException(400, "Skip limit reached") + + if assignment.status != AssignmentStatus.ACTIVE.value: + raise HTTPException(400, "Can only skip active assignments") + + # Получаем game_id + if assignment.is_playthrough: + game_id = assignment.game_id + else: + game_id = assignment.challenge.game_id + + # Consume from inventory + item = await self._consume_item(db, user, ConsumableType.SKIP_EXILE.value) + + # Mark assignment as dropped (без штрафа) + assignment.status = AssignmentStatus.DROPPED.value + assignment.completed_at = datetime.utcnow() + + # Track skip usage + participant.skips_used += 1 + + # НОВОЕ: Добавляем игру в exiled + exiled = ExiledGame( + participant_id=participant.id, + game_id=game_id, + exiled_by="user", + ) + db.add(exiled) + + # Log usage + usage = ConsumableUsage(...) + db.add(usage) + + return { + "success": True, + "skipped": True, + "exiled": True, + "game_id": game_id, + "penalty": 0, + "streak_preserved": True, + } +``` + +#### Изменение get_available_games_for_participant +```python +# backend/app/api/v1/games.py + +async def get_available_games_for_participant(...): + # ... existing code ... + + # НОВОЕ: Получаем изгнанные игры + exiled_result = await db.execute( + select(ExiledGame.game_id) + .where(ExiledGame.participant_id == participant.id) + ) + exiled_game_ids = set(exiled_result.scalars().all()) + + # Фильтруем доступные игры + available_games = [] + for game in games_with_content: + # НОВОЕ: Исключаем изгнанные игры + if game.id in exiled_game_ids: + continue + + if game.game_type == GameType.PLAYTHROUGH.value: + if game.id not in finished_playthrough_game_ids: + available_games.append(game) + else: + # ...existing logic... +``` + +### 1.3 Frontend + +#### Обновление UI использования консамблов +- В `PlayPage.tsx` добавить кнопку "Скип с изгнанием" рядом с обычным скипом +- Показывать предупреждение: "Игра будет навсегда исключена из вашего пула" +- В инвентаре показывать оба типа скипов отдельно + +### 1.4 API Endpoints + +``` +POST /shop/use +Body: { + "item_code": "skip_exile", + "marathon_id": 123, + "assignment_id": 456 +} + +Response: { + "success": true, + "remaining_quantity": 2, + "effect_description": "Задание пропущено, игра изгнана", + "effect_data": { + "skipped": true, + "exiled": true, + "game_id": 789, + "penalty": 0, + "streak_preserved": true + } +} +``` + +--- + +## 2. Модерация марафона (скипы организаторами) + +### 2.1 Концепция + +Организаторы марафона могут скипать задания у участников: +- **Скип** — пропустить задание без штрафа (игра может выпасть снова) +- **Скип с изгнанием** — пропустить и исключить игру из пула участника + +Причины использования: +- Участник просит пропустить игру (технические проблемы, неподходящая игра) +- Модерация спорных ситуаций +- Исправление ошибок + +### 2.2 Backend + +#### Новые эндпоинты +```python +# backend/app/api/v1/marathons.py + +@router.post("/{marathon_id}/participants/{user_id}/skip-assignment") +async def organizer_skip_assignment( + marathon_id: int, + user_id: int, + data: OrganizerSkipRequest, + current_user: CurrentUser, + db: DbSession, +): + """ + Организатор скипает текущее задание участника. + + Body: + exile: bool = False # Если true — скип с изгнанием + reason: str | None # Причина (опционально) + """ + await require_organizer(db, current_user, marathon_id) + + # Получаем участника + participant = await get_participant_by_user_id(db, user_id, marathon_id) + if not participant: + raise HTTPException(404, "Participant not found") + + # Получаем активное задание + assignment = await get_active_assignment(db, participant.id) + if not assignment: + raise HTTPException(400, "No active assignment") + + # Определяем game_id + if assignment.is_playthrough: + game_id = assignment.game_id + else: + game_id = assignment.challenge.game_id + + # Скипаем + assignment.status = AssignmentStatus.DROPPED.value + assignment.completed_at = datetime.utcnow() + + # НЕ увеличиваем skips_used (это модераторский скип, не консамбл) + # НЕ сбрасываем стрик + # НЕ увеличиваем drop_count + + # Если exile — добавляем в exiled + if data.exile: + exiled = ExiledGame( + participant_id=participant.id, + game_id=game_id, + exiled_by="organizer", + reason=data.reason, + ) + db.add(exiled) + + # Логируем в 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, + "assignment_id": assignment.id, + "game_id": game_id, + "exile": data.exile, + "reason": data.reason, + } + ) + db.add(activity) + + await db.commit() + + return {"success": True, "exiled": data.exile} + + +@router.get("/{marathon_id}/participants/{user_id}/exiled-games") +async def get_participant_exiled_games( + marathon_id: int, + user_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Список изгнанных игр участника (для организаторов)""" + await require_organizer(db, current_user, marathon_id) + # ... + + +@router.delete("/{marathon_id}/participants/{user_id}/exiled-games/{game_id}") +async def remove_exiled_game( + marathon_id: int, + user_id: int, + game_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Убрать игру из изгнанных (вернуть в пул)""" + await require_organizer(db, current_user, marathon_id) + # ... +``` + +#### Схемы +```python +# backend/app/schemas/marathon.py + +class OrganizerSkipRequest(BaseModel): + exile: bool = False + reason: str | None = None + +class ExiledGameResponse(BaseModel): + id: int + game_id: int + game_title: str + exiled_at: datetime + exiled_by: str + reason: str | None +``` + +### 2.3 Frontend + +#### Страница участников марафона +В списке участников (`MarathonPage.tsx` или отдельная страница модерации): + +```tsx +// Для каждого участника с активным заданием показываем кнопки: + + +``` + +#### Модальное окно скипа +```tsx + +

Скип задания у {participant.nickname}

+

Текущее задание: {assignment.game.title}

+ + + +