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

@@ -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),

View File

@@ -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')

View File

@@ -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:

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")

View File

@@ -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}")

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"

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"⏭️ <b>Задание пропущено</b>\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"🎁 <b>Вы получили подарок!</b>\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()

View File

@@ -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
// Для каждого участника с активным заданием показываем кнопки:
<button onClick={() => skipAssignment(userId, false)}>
Скип
</button>
<button onClick={() => skipAssignment(userId, true)}>
Скип с изгнанием
</button>
```
#### Модальное окно скипа
```tsx
<Modal>
<h2>Скип задания у {participant.nickname}</h2>
<p>Текущее задание: {assignment.game.title}</p>
<label>
<input type="checkbox" checked={exile} onChange={...} />
Изгнать игру (не будет выпадать снова)
</label>
<textarea placeholder="Причина (опционально)" />
<button>Подтвердить</button>
</Modal>
```
### 2.4 Telegram уведомления
При модераторском скипе отправляем уведомление участнику:
```python
# backend/app/services/telegram_notifier.py
async def notify_assignment_skipped_by_moderator(
user: User,
marathon_title: str,
game_title: str,
exiled: bool,
reason: str | None,
moderator_nickname: str,
):
"""Уведомление о скипе задания организатором"""
if not user.telegram_id or not user.notify_moderation:
return
exile_text = "\n🚫 Игра исключена из вашего пула" if exiled else ""
reason_text = f"\n📝 Причина: {reason}" if reason else ""
message = f"""⏭️ <b>Задание пропущено</b>
Марафон: {marathon_title}
Игра: {game_title}
Организатор: {moderator_nickname}{exile_text}{reason_text}
Вы можете крутить колесо заново."""
await self._send_message(user.telegram_id, message)
```
#### Добавить поле notify_moderation в User
```python
# backend/app/models/user.py
class User(Base):
# ... existing fields ...
notify_moderation: bool = True # Уведомления о действиях модераторов
```
#### Интеграция в эндпоинт
```python
# В organizer_skip_assignment после db.commit():
await telegram_notifier.notify_assignment_skipped_by_moderator(
user=target_user,
marathon_title=marathon.title,
game_title=game.title,
exiled=data.exile,
reason=data.reason,
moderator_nickname=current_user.nickname,
)
```
---
## 3. Выдача предметов админами
### 3.1 Backend
#### Новые эндпоинты
```python
# backend/app/api/v1/shop.py
@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,
):
"""
Выдать предмет пользователю (admin only).
Body:
item_id: int # ID предмета в магазине
quantity: int = 1 # Количество (для консамблов)
reason: str # Причина выдачи
"""
require_admin_with_2fa(current_user)
# Получаем пользователя
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(404, "User not found")
# Получаем предмет
item = await shop_service.get_item_by_id(db, data.item_id)
if not item:
raise HTTPException(404, "Item not found")
# Проверяем quantity для не-консамблов
if item.item_type != "consumable" and data.quantity > 1:
raise HTTPException(400, "Non-consumables can only have quantity 1")
# Проверяем, есть ли уже такой предмет
existing = await db.execute(
select(UserInventory)
.where(
UserInventory.user_id == user_id,
UserInventory.item_id == item.id,
)
)
inv_item = existing.scalar_one_or_none()
if inv_item:
if item.item_type == "consumable":
inv_item.quantity += data.quantity
else:
raise HTTPException(400, "User already owns this item")
else:
inv_item = UserInventory(
user_id=user_id,
item_id=item.id,
quantity=data.quantity if item.item_type == "consumable" else 1,
)
db.add(inv_item)
# Логируем
log = AdminLog(
admin_id=current_user.id,
action="ITEM_GRANT",
target_type="user",
target_id=user_id,
details={
"item_id": item.id,
"item_name": item.name,
"quantity": data.quantity,
"reason": data.reason,
}
)
db.add(log)
await db.commit()
return MessageResponse(
message=f"Granted {data.quantity}x {item.name} 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,
):
"""Получить инвентарь пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
@router.delete("/admin/users/{user_id}/inventory/{inventory_id}", response_model=MessageResponse)
async def admin_remove_item(
user_id: int,
inventory_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Удалить предмет из инвентаря пользователя (admin only)"""
require_admin_with_2fa(current_user)
# ...
```
#### Схемы
```python
class AdminGrantItemRequest(BaseModel):
item_id: int
quantity: int = 1
reason: str
```
### 3.2 Frontend
#### Новая страница: AdminItemsPage
`frontend/src/pages/admin/AdminItemsPage.tsx`
```tsx
export function AdminItemsPage() {
const [users, setUsers] = useState<User[]>([])
const [items, setItems] = useState<ShopItem[]>([])
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [grantModal, setGrantModal] = useState(false)
return (
<div>
<h1>Выдача предметов</h1>
{/* Поиск пользователя */}
<UserSearch onSelect={setSelectedUser} />
{selectedUser && (
<>
{/* Информация о пользователе */}
<UserCard user={selectedUser} />
{/* Инвентарь пользователя */}
<h2>Инвентарь</h2>
<UserInventoryList userId={selectedUser.id} />
{/* Кнопка выдачи */}
<button onClick={() => setGrantModal(true)}>
Выдать предмет
</button>
</>
)}
{/* Модалка выдачи */}
<GrantItemModal
isOpen={grantModal}
user={selectedUser}
items={items}
onClose={() => setGrantModal(false)}
onGrant={handleGrant}
/>
</div>
)
}
```
#### Компонент GrantItemModal
```tsx
function GrantItemModal({ isOpen, user, items, onClose, onGrant }) {
const [itemId, setItemId] = useState<number | null>(null)
const [quantity, setQuantity] = useState(1)
const [reason, setReason] = useState("")
const selectedItem = items.find(i => i.id === itemId)
const isConsumable = selectedItem?.item_type === "consumable"
return (
<Modal isOpen={isOpen} onClose={onClose}>
<h2>Выдать предмет: {user?.nickname}</h2>
{/* Выбор предмета */}
<select value={itemId} onChange={e => setItemId(Number(e.target.value))}>
<option value="">Выберите предмет</option>
{items.map(item => (
<option key={item.id} value={item.id}>
{item.name} ({item.item_type})
</option>
))}
</select>
{/* Количество (только для консамблов) */}
{isConsumable && (
<input
type="number"
min={1}
max={100}
value={quantity}
onChange={e => setQuantity(Number(e.target.value))}
/>
)}
{/* Причина */}
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
placeholder="Причина выдачи (обязательно)"
required
/>
<button
onClick={() => onGrant({ itemId, quantity, reason })}
disabled={!itemId || !reason}
>
Выдать
</button>
</Modal>
)
}
```
#### Добавление в роутер
```tsx
// frontend/src/App.tsx
import { AdminItemsPage } from '@/pages/admin/AdminItemsPage'
// В админских роутах:
<Route path="items" element={<AdminItemsPage />} />
```
#### Добавление в меню админки
```tsx
// frontend/src/pages/admin/AdminLayout.tsx
const adminLinks = [
{ path: '/admin', label: 'Дашборд' },
{ path: '/admin/users', label: 'Пользователи' },
{ path: '/admin/marathons', label: 'Марафоны' },
{ path: '/admin/items', label: 'Предметы' }, // NEW
{ path: '/admin/promo', label: 'Промокоды' },
// ...
]
```
---
## 4. Миграции
### 4.1 Создание таблицы exiled_games
```python
# backend/alembic/versions/XXX_add_exiled_games.py
def upgrade():
op.create_table(
'exiled_games',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('participant_id', sa.Integer(), sa.ForeignKey('participants.id', ondelete='CASCADE'), nullable=False),
sa.Column('game_id', sa.Integer(), sa.ForeignKey('games.id', ondelete='CASCADE'), nullable=False),
sa.Column('assignment_id', sa.Integer(), sa.ForeignKey('assignments.id', ondelete='SET NULL'), nullable=True),
sa.Column('exiled_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('exiled_by', sa.String(20), nullable=False), # user, organizer, admin
sa.Column('reason', sa.String(500), nullable=True),
# История восстановления
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('unexiled_at', sa.DateTime(), nullable=True),
sa.Column('unexiled_by', sa.String(20), nullable=True),
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'])
def downgrade():
op.drop_table('exiled_games')
```
### 4.2 Добавление поля notify_moderation в users
```python
def upgrade():
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade():
op.drop_column('users', 'notify_moderation')
```
### 4.3 Добавление предмета skip_exile
```python
# Можно через миграцию или вручную через админку
# Если через миграцию:
def upgrade():
# ... create table ...
# Добавляем предмет в магазин
op.execute("""
INSERT INTO shop_items (item_type, code, name, description, price, rarity, is_active, created_at)
VALUES (
'consumable',
'skip_exile',
'Скип с изгнанием',
'Пропустить текущее задание без штрафа. Игра навсегда исключается из вашего пула.',
150,
'rare',
true,
NOW()
)
""")
```
---
## 5. Чеклист реализации
### Backend — Модели и миграции
- [ ] Создать модель ExiledGame (с полями assignment_id, is_active, unexiled_at, unexiled_by)
- [ ] Добавить поле notify_moderation в User
- [ ] Добавить ConsumableType.SKIP_EXILE
- [ ] Написать миграцию для exiled_games
- [ ] Написать миграцию для notify_moderation
- [ ] Добавить предмет skip_exile в магазин
### Backend — Скип с изгнанием
- [ ] Реализовать use_skip_exile в ConsumablesService
- [ ] Обновить get_available_games_for_participant (фильтр по is_active=True)
- [ ] Добавить обработку skip_exile в POST /shop/use
### Backend — Модерация
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/skip-assignment
- [ ] Добавить эндпоинт GET /{marathon_id}/participants/{user_id}/exiled-games
- [ ] Добавить эндпоинт POST /{marathon_id}/participants/{user_id}/exiled-games/{game_id}/restore
- [ ] Добавить notify_assignment_skipped_by_moderator в telegram_notifier
### Backend — Админка предметов
- [ ] Добавить эндпоинт POST /shop/admin/users/{user_id}/items/grant
- [ ] Добавить эндпоинт GET /shop/admin/users/{user_id}/inventory
- [ ] Добавить эндпоинт DELETE /shop/admin/users/{user_id}/inventory/{inventory_id}
### Frontend — Игрок
- [ ] Добавить кнопку "Скип с изгнанием" в PlayPage
- [ ] Добавить чекбокс notify_moderation в настройках профиля
### Frontend — Админка
- [ ] Создать AdminItemsPage
- [ ] Добавить GrantItemModal
- [ ] Добавить роут /admin/items
- [ ] Добавить пункт меню в AdminLayout
### Frontend — Модерация марафона
- [ ] Создать UI модерации для организаторов (скип заданий)
- [ ] Добавить список изгнанных игр участника
- [ ] Добавить кнопку восстановления игры в пул
### Тестирование
- [ ] Тест: use_skip_exile корректно исключает игру
- [ ] Тест: изгнанная игра не выпадает при спине
- [ ] Тест: восстановленная игра (is_active=False) снова выпадает
- [ ] Тест: организатор может скипать задания
- [ ] Тест: Telegram уведомление отправляется при модераторском скипе
- [ ] Тест: админ может выдавать предметы
- [ ] Тест: лимиты скипов работают корректно
---
## 6. Вопросы для обсуждения
1. **Лимиты изгнания**: Нужен ли лимит на количество изгнанных игр у участника?
2. **Отмена изгнания**: Может ли участник сам отменить изгнание? Или только организатор?
3. **Стоимость**: Текущая цена skip_exile = 150 монет (обычный skip = 50). Подходит?
4. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?

View File

@@ -44,6 +44,7 @@ import {
AdminBroadcastPage,
AdminContentPage,
AdminPromoCodesPage,
AdminGrantItemPage,
} from '@/pages/admin'
// Protected route wrapper
@@ -241,6 +242,7 @@ function App() {
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="promo" element={<AdminPromoCodesPage />} />
<Route path="logs" element={<AdminLogsPage />} />

View File

@@ -1,5 +1,5 @@
import client from './client'
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute, ExiledGame } from '@/types'
export interface CreateMarathonData {
title: string
@@ -112,4 +112,36 @@ export const marathonsApi = {
)
return response.data
},
// === Moderation ===
// Skip participant's assignment (organizer only)
skipParticipantAssignment: async (
marathonId: number,
userId: number,
exile: boolean = false,
reason?: string
): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/participants/${userId}/skip-assignment`,
{ exile, reason }
)
return response.data
},
// Get participant's exiled games (organizer only)
getExiledGames: async (marathonId: number, userId: number): Promise<ExiledGame[]> => {
const response = await client.get<ExiledGame[]>(
`/marathons/${marathonId}/participants/${userId}/exiled-games`
)
return response.data
},
// Restore exiled game (organizer only)
restoreExiledGame: async (marathonId: number, userId: number, gameId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/participants/${userId}/exiled-games/${gameId}/restore`
)
return response.data
},
}

View File

@@ -106,4 +106,31 @@ export const shopApi = {
})
return response.data
},
// === Админские функции ===
// Получить инвентарь пользователя (админ)
adminGetUserInventory: async (userId: number, itemType?: ShopItemType): Promise<InventoryItem[]> => {
const params = itemType ? { item_type: itemType } : {}
const response = await client.get<InventoryItem[]>(`/shop/admin/users/${userId}/inventory`, { params })
return response.data
},
// Выдать предмет пользователю (админ)
adminGrantItem: async (userId: number, itemId: number, quantity: number, reason: string): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/shop/admin/users/${userId}/items/grant`, {
item_id: itemId,
quantity,
reason,
})
return response.data
},
// Удалить предмет из инвентаря пользователя (админ)
adminRemoveItem: async (userId: number, inventoryId: number, quantity: number = 1): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>(`/shop/admin/users/${userId}/inventory/${inventoryId}`, {
params: { quantity },
})
return response.data
},
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
import { GlassCard, UserAvatar } from '@/components/ui'
import type { LeaderboardEntry, ShopItemPublic, User, Marathon } from '@/types'
import { GlassCard, UserAvatar, NeonButton } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
import { useToast } from '@/store/toast'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target, SkipForward, X, Ban } from 'lucide-react'
// Helper to get name color styles and animation class
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
@@ -80,25 +81,67 @@ function StyledNickname({ user, className = '' }: { user: User; className?: stri
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Skip modal state
const [skipModalUser, setSkipModalUser] = useState<User | null>(null)
const [skipExile, setSkipExile] = useState(false)
const [skipReason, setSkipReason] = useState('')
const [isSkipping, setIsSkipping] = useState(false)
const isOrganizer = marathon?.my_participation?.role === 'organizer' || user?.role === 'admin'
useEffect(() => {
loadLeaderboard()
loadData()
}, [id])
const loadLeaderboard = async () => {
const loadData = async () => {
if (!id) return
try {
const data = await marathonsApi.getLeaderboard(parseInt(id))
setLeaderboard(data)
const [leaderboardData, marathonData] = await Promise.all([
marathonsApi.getLeaderboard(parseInt(id)),
marathonsApi.get(parseInt(id)),
])
setLeaderboard(leaderboardData)
setMarathon(marathonData)
} catch (error) {
console.error('Failed to load leaderboard:', error)
console.error('Failed to load data:', error)
} finally {
setIsLoading(false)
}
}
const handleSkip = async () => {
if (!skipModalUser || !id) return
setIsSkipping(true)
try {
await marathonsApi.skipParticipantAssignment(
parseInt(id),
skipModalUser.id,
skipExile,
skipReason || undefined
)
toast.success(
skipExile
? `Задание ${skipModalUser.nickname} пропущено, игра изгнана`
: `Задание ${skipModalUser.nickname} пропущено`
)
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось пропустить задание')
} finally {
setIsSkipping(false)
}
}
const getRankConfig = (rank: number) => {
switch (rank) {
case 1:
@@ -366,6 +409,20 @@ export function LeaderboardPage() {
</div>
)}
{/* Skip button for organizers */}
{isOrganizer && (
<button
onClick={(e) => {
e.preventDefault()
setSkipModalUser(entry.user)
}}
className="relative p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Скипнуть задание"
>
<SkipForward className="w-4 h-4" />
</button>
)}
{/* Points */}
<div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
@@ -380,6 +437,104 @@ export function LeaderboardPage() {
</GlassCard>
</>
)}
{/* Skip Modal */}
{skipModalUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<SkipForward className="w-5 h-5 text-orange-400" />
Скипнуть задание {skipModalUser.nickname}
</h3>
<button
onClick={() => {
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Skip type */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип скипа
</label>
<div className="space-y-2">
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-orange-500/30 transition-colors">
<input
type="radio"
name="skipType"
checked={!skipExile}
onChange={() => setSkipExile(false)}
className="w-4 h-4 text-orange-500 bg-dark-700 border-dark-500 focus:ring-orange-500/50"
/>
<div>
<div className="text-white font-medium">Обычный скип</div>
<div className="text-sm text-gray-400">Задание пропускается, игра может выпасть снова</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-red-500/30 transition-colors">
<input
type="radio"
name="skipType"
checked={skipExile}
onChange={() => setSkipExile(true)}
className="w-4 h-4 text-red-500 bg-dark-700 border-dark-500 focus:ring-red-500/50"
/>
<div>
<div className="text-white font-medium flex items-center gap-2">
Скип с изгнанием
<Ban className="w-4 h-4 text-red-400" />
</div>
<div className="text-sm text-gray-400">Игра навсегда удаляется из пула участника</div>
</div>
</label>
</div>
</div>
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина (опционально)
</label>
<textarea
value={skipReason}
onChange={(e) => setSkipReason(e.target.value)}
placeholder="Причина скипа..."
rows={2}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setSkipModalUser(null)
setSkipExile(false)
setSkipReason('')
}}
>
Отмена
</NeonButton>
<NeonButton
color={skipExile ? 'pink' : 'neon'}
onClick={handleSkip}
disabled={isSkipping}
isLoading={isSkipping}
icon={<SkipForward className="w-4 h-4" />}
>
{skipExile ? 'Скипнуть и изгнать' : 'Скипнуть'}
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -494,6 +494,35 @@ export function PlayPage() {
}
}
const handleUseSkipExile = async () => {
if (!currentAssignment || !id) return
const confirmed = await confirm({
title: 'Скип с изгнанием?',
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsUsingConsumable('skip_exile')
try {
await shopApi.useConsumable({
item_code: 'skip_exile',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено, игра изгнана из пула!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
@@ -826,6 +855,28 @@ export function PlayPage() {
</NeonButton>
</div>
{/* Skip with Exile */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Skip + Изгнание</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skip_exiles_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkipExile}
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip_exile'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">

View File

@@ -0,0 +1,404 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { shopApi, adminApi } from '@/api'
import { useToast } from '@/store/toast'
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
import {
Loader2, Gift, ArrowLeft, Package,
Frame, Type, Palette, Image, Zap, SkipForward,
Minus, Plus, Shuffle, Dice5, Copy, Undo2, X, XCircle
} from 'lucide-react'
import type { ShopItem, ShopItemType, ShopItemPublic, AdminUser } from '@/types'
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
import clsx from 'clsx'
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
frame: <Frame className="w-5 h-5" />,
title: <Type className="w-5 h-5" />,
name_color: <Palette className="w-5 h-5" />,
background: <Image className="w-5 h-5" />,
consumable: <Zap className="w-5 h-5" />,
}
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
skip: <SkipForward className="w-8 h-8" />,
skip_exile: <XCircle className="w-8 h-8" />,
boost: <Zap className="w-8 h-8" />,
wild_card: <Shuffle className="w-8 h-8" />,
lucky_dice: <Dice5 className="w-8 h-8" />,
copycat: <Copy className="w-8 h-8" />,
undo: <Undo2 className="w-8 h-8" />,
}
const ITEM_TYPE_LABELS: Record<ShopItemType | 'all', string> = {
all: 'Все',
consumable: 'Расходники',
frame: 'Рамки',
title: 'Титулы',
name_color: 'Цвета',
background: 'Фоны',
}
interface GrantItemCardProps {
item: ShopItem
onGrant: (item: ShopItem) => void
}
function GrantItemCard({ item, onGrant }: GrantItemCardProps) {
const rarityColors = RARITY_COLORS[item.rarity]
const getItemPreview = () => {
if (item.item_type === 'consumable') {
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
}
if (item.item_type === 'name_color') {
const data = item.asset_data as { style?: string; color?: string; gradient?: string[] } | null
if (data?.style === 'gradient' && data.gradient) {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
/>
)
}
if (data?.style === 'animated') {
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
style={{
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
backgroundSize: '400% 400%'
}}
/>
)
}
const solidColor = data?.color || '#ffffff'
return (
<div
className="w-12 h-12 rounded-full border-2 border-dark-600"
style={{ backgroundColor: solidColor }}
/>
)
}
if (item.item_type === 'background') {
const data = item.asset_data as { type?: string; color?: string; gradient?: string[] } | null
let bgStyle: React.CSSProperties = {}
if (data?.type === 'solid' && data.color) {
bgStyle = { backgroundColor: data.color }
} else if (data?.type === 'gradient' && data.gradient) {
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
}
return (
<div
className="w-16 h-12 rounded-lg border-2 border-dark-600"
style={bgStyle}
/>
)
}
if (item.item_type === 'frame') {
const frameItem: ShopItemPublic = {
id: item.id,
code: item.code,
name: item.name,
item_type: item.item_type,
rarity: item.rarity,
asset_data: item.asset_data,
}
return <FramePreview frame={frameItem} size="lg" />
}
if (item.item_type === 'title' && item.asset_data?.text) {
return (
<span
className="text-lg font-bold"
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
>
{item.asset_data.text as string}
</span>
)
}
return ITEM_TYPE_ICONS[item.item_type]
}
return (
<GlassCard
className={clsx(
'p-4 border transition-all duration-300 hover:scale-[1.02]',
rarityColors.border
)}
>
{/* Rarity badge */}
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
{RARITY_NAMES[item.rarity]}
</div>
{/* Item preview */}
<div className="flex justify-center items-center h-20 mb-3">
{getItemPreview()}
</div>
{/* Item info */}
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
{item.description}
</p>
{/* Grant button */}
<NeonButton
size="sm"
color="neon"
onClick={() => onGrant(item)}
className="w-full"
icon={<Gift className="w-4 h-4" />}
>
Выдать
</NeonButton>
</GlassCard>
)
}
export function AdminGrantItemPage() {
const { userId } = useParams<{ userId: string }>()
const navigate = useNavigate()
const toast = useToast()
const [user, setUser] = useState<AdminUser | null>(null)
const [items, setItems] = useState<ShopItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
// Grant modal
const [grantItem, setGrantItem] = useState<ShopItem | null>(null)
const [grantQuantity, setGrantQuantity] = useState(1)
const [grantReason, setGrantReason] = useState('')
const [isGranting, setIsGranting] = useState(false)
useEffect(() => {
loadData()
}, [userId])
const loadData = async () => {
if (!userId) return
setIsLoading(true)
try {
const [userData, itemsData] = await Promise.all([
adminApi.getUser(parseInt(userId)),
shopApi.getItems(),
])
setUser(userData)
setItems(itemsData)
} catch (err) {
console.error('Failed to load data:', err)
toast.error('Ошибка загрузки данных')
} finally {
setIsLoading(false)
}
}
const handleGrant = async () => {
if (!grantItem || !userId || !grantReason.trim()) return
setIsGranting(true)
try {
await shopApi.adminGrantItem(
parseInt(userId),
grantItem.id,
grantQuantity,
grantReason
)
toast.success(`Выдано ${grantItem.name} x${grantQuantity} для ${user?.nickname}`)
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Ошибка выдачи предмета')
} finally {
setIsGranting(false)
}
}
const filteredItems = activeTab === 'all'
? items
: items.filter(item => item.item_type === activeTab)
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (!user) {
return (
<div className="text-center py-24">
<p className="text-gray-400">Пользователь не найден</p>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/admin/users')}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Gift className="w-7 h-7 text-green-400" />
Выдать предмет
</h1>
<p className="text-gray-400">
Получатель: <span className="text-white font-medium">{user.nickname}</span>
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 flex-wrap">
{(Object.keys(ITEM_TYPE_LABELS) as (ShopItemType | 'all')[]).map((type) => (
<button
key={type}
onClick={() => setActiveTab(type)}
className={clsx(
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
activeTab === type
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
: 'bg-dark-700/50 text-gray-400 border border-dark-600 hover:text-white hover:border-dark-500'
)}
>
{ITEM_TYPE_LABELS[type]}
</button>
))}
</div>
{/* Items grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map(item => (
<GrantItemCard
key={item.id}
item={item}
onGrant={setGrantItem}
/>
))}
</div>
{filteredItems.length === 0 && (
<div className="text-center py-12">
<Package className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400">Нет предметов в этой категории</p>
</div>
)}
{/* Grant Modal */}
{grantItem && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Gift className="w-5 h-5 text-green-400" />
Выдать {grantItem.name}
</h3>
<button
onClick={() => {
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-400 mb-4">
Получатель: <span className="text-white">{user.nickname}</span>
</p>
{/* Quantity */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Количество
</label>
<div className="flex items-center gap-3">
<button
onClick={() => setGrantQuantity(Math.max(1, grantQuantity - 1))}
disabled={grantQuantity <= 1}
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Minus className="w-5 h-5" />
</button>
<input
type="number"
min="1"
max="100"
value={grantQuantity}
onChange={(e) => setGrantQuantity(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
className="w-20 text-center bg-dark-700/50 border border-dark-600 rounded-xl px-3 py-2 text-white font-bold text-lg focus:outline-none focus:border-neon-500/50"
/>
<button
onClick={() => setGrantQuantity(Math.min(100, grantQuantity + 1))}
disabled={grantQuantity >= 100}
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина <span className="text-red-400">*</span>
</label>
<textarea
value={grantReason}
onChange={(e) => setGrantReason(e.target.value)}
placeholder="Причина выдачи предмета..."
rows={3}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-neon-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setGrantItem(null)
setGrantQuantity(1)
setGrantReason('')
}}
>
Отмена
</NeonButton>
<NeonButton
color="neon"
onClick={handleGrant}
disabled={!grantReason.trim() || isGranting}
isLoading={isGranting}
icon={<Gift className="w-4 h-4" />}
>
Выдать x{grantQuantity}
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { adminApi } from '@/api'
import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff, Gift } from 'lucide-react'
export function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
@@ -319,6 +320,14 @@ export function AdminUsersPage() {
>
<KeyRound className="w-4 h-4" />
</button>
<Link
to={`/admin/users/${user.id}/grant-item`}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Выдать предмет"
>
<Gift className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
@@ -512,6 +521,7 @@ export function AdminUsersPage() {
</div>
</div>
)}
</div>
)
}

View File

@@ -6,3 +6,4 @@ export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'
export { AdminPromoCodesPage } from './AdminPromoCodesPage'
export { AdminGrantItemPage } from './AdminGrantItemPage'

View File

@@ -181,6 +181,15 @@ export interface GameShort {
game_type?: GameType
}
export interface ExiledGame {
id: number
game_id: number
game_title: string
exiled_at: string
exiled_by: 'user' | 'organizer' | 'admin'
reason: string | null
}
export interface AvailableGamesCount {
available: number
total: number
@@ -719,7 +728,7 @@ export interface PasswordChangeData {
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
export type ConsumableType = 'skip' | 'skip_exile' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
export interface ShopItemPublic {
id: number
@@ -806,6 +815,7 @@ export interface CoinsBalance {
export interface ConsumablesStatus {
skips_available: number
skip_exiles_available: number
skips_used: number
skips_remaining: number | null
boosts_available: number