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