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