Добавлен 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:
@@ -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),
|
||||
|
||||
65
backend/alembic/versions/030_add_exiled_games.py
Normal file
65
backend/alembic/versions/030_add_exiled_games.py
Normal 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')
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ class ActivityType(str, Enum):
|
||||
EVENT_END = "event_end"
|
||||
SWAP = "swap"
|
||||
GAME_CHOICE = "game_choice"
|
||||
MODERATION = "moderation"
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
|
||||
37
backend/app/models/exiled_game.py
Normal file
37
backend/app/models/exiled_game.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user