## 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>
790 lines
26 KiB
Markdown
790 lines
26 KiB
Markdown
# ТЗ: Скип с изгнанием, модерация и выдача предметов
|
||
|
||
## Обзор
|
||
|
||
Три связанные фичи:
|
||
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. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?
|