Добавлен 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:
789
docs/tz-skip-exile-moderation.md
Normal file
789
docs/tz-skip-exile-moderation.md
Normal 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. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?
|
||||
Reference in New Issue
Block a user