# Система оспаривания (Disputes) Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность. ## Общий принцип работы ``` ┌──────────────────────────────────────────────────────────────────────────┐ │ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │ └──────────────────────────────────────────────────────────────────────────┘ Участник A Участник B Все участники выполняет задание замечает проблему голосуют │ │ │ ▼ ▼ ▼ ┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐ │ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │ │ │ окно оспаривания │ (OPEN) │ голосование │ │ └───────────┘ └───────────┘ └───────────┘ │ │ │ │ │ ├──▶ VALID (пруф OK) │ │ │ Задание остаётся │ │ │ │ │ └──▶ INVALID (пруф не OK) │ │ Задание возвращается │ │ └──────────────────────────────────┘ Если не оспорено — задание засчитано ``` ## Кто может оспаривать | Условие | Можно оспорить? | |---------|-----------------| | Своё задание | ❌ Нельзя | | Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) | | Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя | | Прошло более 24 часов с момента выполнения | ❌ Нельзя | | Уже есть активный диспут на это задание | ❌ Нельзя | ## Типы оспариваемых заданий ### 1. Обычные челленджи Можно оспорить выполнение любого челленджа. При признании пруфа невалидным: - Задание переходит в статус `RETURNED` - Очки снимаются с участника - Участник должен переделать задание ### 2. Прохождения игр (Playthrough) Основное задание прохождения можно оспорить. При признании невалидным: - Основное задание переходит в статус `RETURNED` - Очки снимаются - **Все бонусные челленджи сбрасываются** в статус `PENDING` ### 3. Бонусные челленджи Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным: - Только этот бонусный челлендж сбрасывается в `PENDING` - Участник может переделать его - Основное задание и другие бонусы не затрагиваются **Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус. ## Процесс голосования ### Создание диспута 1. Участник нажимает "Оспорить" на странице деталей задания 2. Вводит причину оспаривания (минимум 10 символов) 3. Создаётся диспут со статусом `OPEN` 4. Владельцу задания отправляется уведомление в Telegram ### Голосование - **Любой участник марафона** может голосовать - Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK) - Можно **изменить** свой голос до завершения голосования - Голосование длится **24 часа** с момента создания диспута ### Комментарии - Участники могут оставлять комментарии для обсуждения - Комментарии помогают другим участникам принять решение - Комментарии доступны только пока диспут открыт ## Разрешение диспута ### Автоматическое (по таймеру) Через 24 часа диспут автоматически разрешается: - Система подсчитывает голоса - При равенстве голосов — **в пользу обвиняемого** (пруф валиден) - Результат: `RESOLVED_VALID` или `RESOLVED_INVALID` **Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут. ### Результаты | Результат | Условие | Последствия | |-----------|---------|-------------| | `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным | | `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается | ### Что происходит при INVALID **Для обычного задания:** 1. Статус → `RETURNED` 2. Очки (`points_earned`) вычитаются из общего счёта участника 3. Пруфы сохраняются для истории **Для прохождения:** 1. Основное задание → `RETURNED` 2. Очки вычитаются 3. Все бонусные челленджи сбрасываются: - Статус → `PENDING` - Пруфы удаляются - Очки обнуляются **Для бонусного челленджа:** 1. Только этот бонус → `PENDING` 2. Пруфы удаляются 3. Можно переделать ## API эндпоинты ### Создание диспута ``` POST /api/v1/assignments/{assignment_id}/dispute POST /api/v1/bonus-assignments/{bonus_id}/dispute Body: { "reason": "Описание проблемы с пруфом..." } ``` ### Голосование ``` POST /api/v1/disputes/{dispute_id}/vote Body: { "vote": true } // true = валидно, false = невалидно ``` ### Комментарии ``` POST /api/v1/disputes/{dispute_id}/comments Body: { "text": "Текст комментария" } ``` ### Получение информации ``` GET /api/v1/assignments/{assignment_id} // В ответе включено поле dispute с полной информацией: { "dispute": { "id": 1, "status": "open", "reason": "...", "votes_valid": 3, "votes_invalid": 2, "my_vote": true, "expires_at": "2024-12-30T12:00:00Z", "comments": [...], "votes": [...] } } ``` ## Структура базы данных ### Таблица `disputes` | Поле | Тип | Описание | |------|-----|----------| | `id` | INT | PK | | `assignment_id` | INT | FK → assignments (nullable для бонусов) | | `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) | | `raised_by_id` | INT | FK → users | | `reason` | TEXT | Причина оспаривания | | `status` | VARCHAR(20) | open / valid / invalid | | `created_at` | DATETIME | Время создания | | `resolved_at` | DATETIME | Время разрешения | **Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба). ### Таблица `dispute_votes` | Поле | Тип | Описание | |------|-----|----------| | `id` | INT | PK | | `dispute_id` | INT | FK → disputes | | `user_id` | INT | FK → users | | `vote` | BOOLEAN | true = валидно, false = невалидно | | `created_at` | DATETIME | Время голоса | **Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`). ### Таблица `dispute_comments` | Поле | Тип | Описание | |------|-----|----------| | `id` | INT | PK | | `dispute_id` | INT | FK → disputes | | `user_id` | INT | FK → users | | `text` | TEXT | Текст комментария | | `created_at` | DATETIME | Время комментария | ## UI компоненты ### Кнопка "Оспорить" Появляется на странице деталей задания (`/assignments/{id}`) если: - Статус задания: `COMPLETED` - Это не своё задание - Прошло меньше 24 часов с момента выполнения - Нет активного диспута ### Секция диспута Показывается если есть активный или завершённый диспут: - Статус (открыт / валиден / невалиден) - Таймер до окончания (для открытых) - Причина оспаривания - Кнопки голосования с счётчиками - Секция комментариев ### Для бонусных челленджей На каждом бонусном челлендже: - Маленькая кнопка "Оспорить" (если можно) - Бейдж статуса диспута - Компактное голосование прямо в карточке бонуса ## Уведомления ### Telegram уведомления | Событие | Получатель | Сообщение | |---------|------------|-----------| | Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" | | Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" | | Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" | ## Конфигурация ```python # backend/app/api/v1/assignments.py DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута # backend/app/services/dispute_scheduler.py CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут DISPUTE_WINDOW_HOURS = 24 # Время голосования ``` ## Пример сценария ### Сценарий 1: Успешное оспаривание 1. **Иван** выполняет челлендж "Пройти уровень без смертей" 2. **Иван** прикладывает скриншот финального экрана 3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти 4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти" 5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно" 6. Через 24 часа диспут закрывается как `RESOLVED_INVALID` 7. Задание Ивана возвращается, очки снимаются 8. Иван получает уведомление и должен переделать задание ### Сценарий 2: Оспаривание бонуса 1. **Анна** проходит игру и выполняет бонусный челлендж 2. **Сергей** замечает проблему с пруфом бонуса 3. **Сергей** оспаривает только бонусный челлендж 4. Голосование: 4 за "невалидно", 1 за "валидно" 5. Результат: бонус сбрасывается в `PENDING` 6. Основное задание Анны **не затронуто** 7. Анна может переделать бонус (пока основное задание активно) ## Ручное разрешение диспутов Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования. ### Кто может разрешать | Роль | Доступ | |------|--------| | **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) | | **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) | ### Интерфейс для системных админов **Путь:** `/admin/disputes` - Отдельная страница в админ-панели - Фильтры: "Открытые" / "Все" - Показывает диспуты из всех марафонов - Информация: марафон, задание, участник, кто оспорил, причина - Счётчик голосов и время до истечения - Кнопки "Валидно" / "Невалидно" для мгновенного решения ### Интерфейс для организаторов **Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания" - Доступна только организаторам активного марафона - Показывает только диспуты текущего марафона - Компактный вид с возможностью раскрытия - Ссылка на страницу задания для детального просмотра ### API для ручного разрешения **Системные админы:** ``` GET /api/v1/admin/disputes?status_filter=open|all POST /api/v1/admin/disputes/{dispute_id}/resolve Body: { "is_valid": true|false } ``` **Организаторы марафона:** ``` GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve Body: { "is_valid": true|false } ``` ### Что происходит при ручном разрешении Логика идентична автоматическому разрешению: **При `is_valid: true`:** - Диспут закрывается как `RESOLVED_VALID` - Задание остаётся выполненным - Участник получает уведомление **При `is_valid: false`:** - Диспут закрывается как `RESOLVED_INVALID` - Задание возвращается, очки снимаются - Участник получает уведомление ### Важно: логика снятия очков за бонусы При отклонении бонусного диспута система проверяет статус основного прохождения: ``` ┌─────────────────────────────────────────────────────────────────┐ │ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Основное прохождение Основное прохождение │ │ НЕ завершено? УЖЕ завершено? │ │ │ │ │ │ ▼ ▼ │ │ ┌───────────┐ ┌───────────┐ │ │ │ Просто │ │ Вычитаем │ │ │ │ сбросить │ │ очки из │ │ │ │ бонус │ │ участника │ │ │ └───────────┘ └───────────┘ │ │ (очки ещё не (очки уже были │ │ были начислены) начислены при │ │ завершении прохождения) │ └─────────────────────────────────────────────────────────────────┘ ``` **Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание). ## Логирование действий Ручное разрешение диспутов логируется в системе: | Действие | Тип лога | |----------|----------| | Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` | | Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` | Логи доступны в `/admin/logs` для аудита действий администраторов.