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. **Телеграм уведомления**: Нужны ли уведомления участнику при модераторском скипе?
|