diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index e38b5c51..9e80e552 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -17,7 +17,8 @@ jobs: postgres-tests: name: Backend PostgreSQL CI runs-on: ubuntu-latest - timeout-minutes: 30 + # Полный набор вместе с подготовкой превысил 30 минут; состав проверок сохранен. + timeout-minutes: 60 services: postgres: diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 5caecb37..1d9a0d6f 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -1,6 +1,50 @@ + + # C00 Контракт решения в программе -Версия 1.0.0 от 23 октября 2026 года. Контракт фиксирует поведение для реализации BE01–BE06 и FE01–FE06. В BE01 реализованы модели и четыре операции контекста, создания, чтения и списка; остальные операции остаются задачами BE02–BE06. Новый режим выключен по умолчанию. Контракт описывает JSON на проводе в snake_case; преобразование в camelCase выполняет frontend adapter. +Изменение 1.21.0: публичные GET охватывают уже сданные конкурсные решения КЧ/хакатона после окончания программы и установки marker существующим publisher. Неконкурсные сохраненные материалы остаются без сдачи. DTO/156 schemas/59 operations сохраняются;6 новых независимых сценариев, итого305. Временный сценарий published_solution_list_competitive_pending_rule намеренно меняется404→200 с пустой страницей: весь конкурсный список больше не закрыт из-за отдельного вопроса о draft. Остальные298 старых сценариев структурно неизменны. Несданные конкурсные работы и архивы не выдаются, окончательная eligibility draft все еще ожидает ответа. Нового Angular UI, схемы БД или Project нет. + +Рабочая версия 1.20.1 от 25 октября 2026 года. Контракт фиксирует поведение для реализации BE01–BE09 и FE01–FE08. В BE01 реализованы модели и четыре операции контекста, создания, чтения и списка; BE02 добавляет удаление участника, выход и передачу капитанства, BE03 — цикл приглашений, BE04 — чтение/изменение формы и стартовые шаблоны, BE05 — частичное сохранение ответов и привязку файлов, BE06 — атомарную сдачу со снимком. Новый режим выключен по умолчанию. Контракт описывает JSON на проводе в snake_case; преобразование в camelCase выполняет frontend adapter. + +Изменение 1.20.1: исправлен код отказа оценивания архивного решения для пользователя с совмещенными ролями организатора/эксперта: solution_archived/409 проверяется до criteria/quota/assignment. Обычный эксперт без сохраненного доступа по-прежнему получает404. Существующая форма D01 закрывает материалы и ввод при таком отказе. Payload/59 операций/156 схем и297 прежних примеров не меняются; добавлены2 независимых примера. Миграций и изменений макета нет. + +Изменение 1.20.0: BE08C2 добавляет подтверждаемое удаление регистраций по registration_ids и отдельный аудит, включая регистрации без решения. Капитан снимает заявку с участия с reason=registration_removed; участник удаляется из текущего состава/снимка, pending отзываются. Архивные работы, материалы, оценки, Auth User, роли и другие программы сохраняются. Смешанная связь с Project блокирует всю команду. Старые 272 сценария не меняются; добавлены 25 независимых сценариев и 4 операции. Миграция 0038 только добавляет аудит/связи истории, без удаления данных. Новый UI остается за согласованием D02. + +Изменение 1.19.0: bulk archive preview/apply снимает работу с активного участия, сохраняя solution/материалы/UserFile/состав/оценки и исходную сдачу. Pending отзываются, все назначения снимаются; архив не участвует в текущей заявке/эксклюзивности/оценивании/аналитике/публикации. Собственный архив читается капитаном/составом/manager, participation=active(default)/archived/all. Optional participation добавлен в Solution/Summary; прежние payloads остаются валидны. Draft получает archive snapshot без submit. История дополнена archive ветвью и private archive_snapshot detail.9 новых схем/2 операции; итог145 схем/55 операций/272 сценариев,250 старых сценариев сохранены. Регистрации не удаляются этим action; их отдельная административная команда еще в объеме BE08. Backend/миграция → frontend API; новый UI ожидает D02, legacy Project не меняется. + +Изменение 1.18.0: organizer/admin получает отдельные signed preview/apply исключения принятого участника до/после сдачи. Капитан не исключается, регистрация/ответы/файлы/оценки/назначения сохраняются; pending приглашения выбранного участника отзываются, обработанные сохраняются без доступа к материалам. Текущий submitted snapshot меняет только members; исходный и новый snapshot и исключенный Member сохраняются в истории. SolutionAdminChange теперь discriminated union change_case/remove_member; прежние case payloads и все230 сценариев неизменны.5 новых схем/2 операции, итог136 схем/53 операции/250 независимых сценариев. Обычные writers остаются frozen. Удаление регистрации/архив заявки далее; Project endpoints/данные не меняются. Backend/миграция → frontend API, новый UI ожидает D02. + +Изменение 1.17.0: согласованные административные исключения начинают со смены кейса после сдачи. Signed preview/apply сохраняет оценки и оцененные назначения, снимает только несовместимые неоцененные; snapshot до/после и actor/version/effects сохраняются в неизменяемой истории. Обычные participant/model writers остаются frozen. Preview дополнен is_submitted/removed_assignments_count/score_count; два старых submitted mocks намеренно меняют прежний запрет на разрешенное действие. GET history/list и detail доступны только organizer/admin своей программы; snapshot использует существующую SubmissionSnapshot схему.3 новых схемы/2 операции, итог131 схем/51 операция/230 сценариев. Legacy endpoints/Project модели не меняются; удаления/отвязка/исключение участников остаются следующим объемом BE08. Backend/миграция → frontend; нового UI до D02 approval нет. + +Изменение 1.16.0: роль нового приглашения обязательна и содержит 1–128 символов, включая хотя бы один непробельный. PATCH может не передавать role при изменении других полей; переданная role имеет те же ограничения и не очищается в null. Это намеренное ужесточение двух request-схем CreateInvitation/UpdateInvitation под согласованный D00 v3/AC26; старые nullable role в ответах/истории/составе сохранены, accept/decline/revoke не требуют заполнения старой роли. Три прежних create error mocks дополнены корректной ролью, чтобы по-прежнему проверять duplicate/max/frozen; остальные206 старых сценариев неизменны. Добавлены8 независимых сценариев: границы/400/историческая null role. Итог128 схем/49 операций/217 сценариев. Схемы ответов и маршруты прежние; UI уже требует роль, новых экранов/миграций нет. Backend перед frontend. + +Изменение 1.15.0: отдельные solution case preview/apply переносят существующую смену кейса черновика организатором. Signed proof10min, exact case, program/solution/fields locks и state recheck; только case answer/version/time меняются. Submitted остается frozen до отдельного решения об административных исключениях. Старые125 схем/47 операций/185 сценариев, включая submit action, неизменны; добавлены3 схемы/2 операции/24 независимых сценария. Неконкурсные черновики поддерживаются, искусственной сдачи нет. Новый UI/удаление/отвязка этим API не реализуются. + +Изменение 1.13.0: REL01 получает отдельный persisted `PartnerProgram.solution_creation_enabled`, default=False, без изменения storage mode. GET context добавляет значение blocked_reason=solution_creation_disabled; POST нового решения — 409 с тем же code. Повтор капитана возвращает прежнюю заявку с 200; права и currentApplication имеют прежний приоритет. Чтение, ответы, файлы, команда, приглашения, сдача, оценки и публикация существующих работ не отключаются этим флагом. Техническая команда `set_solution_creation --program-id ID --enable|--disable` делает preview, `--apply` меняет только выбранную программу под row lock. Нового API настройки нет. 120 схем/45 операций сохранены, 161 независимый сценарий (все 158 прежних неизменны). Миграция 0034 закрывает новое создание по умолчанию, не меняя данные работ. Backend/миграция → frontend; новый клиент уточняет текст существующего error state, старый использует canCreate. Полный REL01 и QA01 не объявлены завершенными. + +Изменение 1.12.0: manager list добавляет field_id/answer_state по applied snapshot/current форме (0/false present, новое поле не missing у старой сдачи). kind=solutions/submitted XLSX принимает те же search/scope/submission/case/field фильтры и выгружает все найденные без pagination; остальные kind отклоняют непустые фильтры. Readonly manager permissions/no-store, прежние ответы и 120 схем сохранены; 45 операций, 158 независимых сценариев. Angular UI ожидает design approval. + +Изменение 1.11.0: два anonymous read-only GET published-solutions для опубликованных неконкурсных материалов и accepted team. Nullable published_at сохраняет прежнюю одноразовую публикацию после срока/флага; отдельный job не создает Project и не требует submit. Public allowlist не содержит оценок, регистрации, контактных полей профиля или приглашений. Current saved или immutable submitted snapshot, 0/False/file URL, no-store и C00 errors. Прежние 116 схем, 43 операции и 128 сценариев неизменны; итог 120 схем, 45 операций, 144 сценария. Конкурсная публикация ожидает ранее заданного решения о черновиках; публичный UI требует отдельного design approval. + +Изменение 1.10.0: manager-only POST reminder-preview/reminders для ожидающих решений использует настоящие solution IDs и SolutionExpertAssignment, snapshot search, selected/all/exclusions и существующий подписанный preview/outbox. Получатели — только активные эксперты программы с незавершенными назначениями; 0/False учитываются общими правилами. Confirm/delivery пересекают текущие связи с утвержденными IDs, новые назначения не добавляются, повтор token идемпотентен. Private, no-store, C00 errors; legacy/open/noncompetitive неприменимы. Прежние 110 схем, 41 операция и 113 сценариев неизменны; итог 116 схем, 43 операции, 128 сценариев. Angular API layer внедряется независимо, новый UI D02 ожидает design approval. + +Изменение 1.9.0: пять manager-only read/export операций solution-analytics дают сводку, реальные назначения/детали, scoped список решений и XLSX программы. Snapshot/current форма определяет применимость полей; 0/False answered, noncompetitive submission/evaluation и open assignment aggregates null. Старые Project endpoints/DTO не меняются. Прежние 88 схем, 36 операций и 80 сценариев сохранены; итог 110 схем, 41 операций, 113 сценариев. Backend → frontend API layer; UI/напоминания/разрушающие команды отдельно. + +Изменение 1.8.0: GET /programs/{program_id}/management/solution-cases/ дает организатору агрегаты ProgramSolution по текущим options кейса. Submitted использует snapshot, draft — собственные ответы; люди уникальны внутри bucket и только зарегистрированные captain/accepted members. Noncompetitive submission counts null, без требования submit. Старые 85 схем, 35 операций и 72 сценария сохранены; итог 88 схем, 36 операций, 80 сценариев. Старый Project overview не изменяется; новый UI и остальная аналитика внедряются отдельно. + +Изменение 1.7.0: существующий GET /programs/{program_id}/analytics-widget/ добавлен в C00 перед адаптацией. Legacy payload сохраняется. Новый solution payload содержит storage_mode=solution, SolutionRef участника и отдельные счетчики solutions/participants_without_solution организатора; экспертные assigned/remaining читают настоящие назначения. Стадии и null неприменимых показателей прежние, оценки и регистрации не сериализуются. Ошибки сохраняют существующий DRF detail и status, не переводятся молча в C00 Error. Все прежние схемы, операции и 59 примеров неизменны: 85 схем, 35 операций, 72 сценария. Backend → frontend; реальные программы не переключаются. Это контракт ролевого виджета, не завершенная основная аналитика BE09. + +Изменение 1.6.0: перед подключением FE07 синхронизированы последние согласованные правки оценивания из go to UI / PR #506 и API #823. RatingValue.value и RatingCriterion.value расширены с 50 до 1000 символов; текстовые комментарии повторяют текущий ProjectScore. Типы, диапазоны, обязательность, ID и все операции сохранены. Новый независимый пример фиксирует комментарий 1000 символов: 74 схемы, 34 операции, 59 сценариев. Optional RatingPage.evaluation_counts передает собственные счетчики после фильтров и до статуса/пагинации, как новый список проектов из PR #506; UI не считает их из одной страницы. Optional RatingPage.case_options дает эксперту только варианты кейса для текущего фильтра; доступ к закрытой живой форме не расширяется. Backend требует merge migrations для двух веток project_rates.0008 и расширение SolutionScore.value; затем backend → frontend с общей формой критериев, без преобразования ProgramSolution в Project. + +Изменение 1.5.0: GET /programs/{program_id}/management/solution-results/export/ возвращает бинарный XLSX с тем же расчетом, что и результаты решения. Фильтры сохранены; scope=current/all повторяет действующую выгрузку проектов. Два листа содержат ID решения, числовые ячейки и прежние формулы; пользовательские строки защищены от интерпретации как формулы. Доступ только организатору программы; ошибки остаются JSON C00. Старые операции и все схемы не меняются: 74 схемы, 34 операции, 57 независимых примеров. Успешный XLSX проверяется чтением настоящей книги в API tests, JSON-пример нового endpoint фиксирует invalid scope. Это первый блок BE09; остальная аналитика, ролевой виджет и выгрузки еще в работе. + +Изменение 1.4.0: GET /evaluation/applications/ возвращает одну страницу реальных назначений обоих форматов. ApplicationRef явно различает legacy_project и solution; project_id/program_link_id и solution_id не подменяются. Общие count/limit/offset применяются после объединения и фильтрации, а pending/completed/unavailable сохраняют семантику вкладок текущего кабинета. Старые /evaluation/programs/ и include=projects остаются совместимыми; счетчики нового формата читают SolutionExpertAssignment. Контракт содержит 74 схемы, 33 операции и 56 независимых примеров. Это рабочий checkpoint BE08; публичные материалы и остальные административные команды дополняются отдельно до реализации. + +Изменение 1.3.0: совместимое расширение BE07/FE07 добавляет десять операций оценки, назначений, распределения, числа экспертов и результатов решений. Перед реализацией зафиксированы 72 схемы и 55 независимых примеров. В errors появился optional criterion_id; field_id остается ID поля решения. На проводе оценки используют string/null, назначения — настоящие solution_id и Expert.id. Распределение требует ревизию и подписанный предпросмотр; результаты доступны организатору. Старые URL и обязательность полей сохранены. API и frontend port/adapter/use cases реализованы в рабочей ветке; экспертный UI требует согласования D01 и еще не подключен. Rollout: migration project_rates.0008 → backend → frontend; default legacy_project сохранен. + +Изменение 1.2.0: общий POST `/files/` принимает необязательный query-параметр `preserve_original=true`. Он сохраняет исходные байты и формат изображения для материалов решения. Без параметра остается прежнее преобразование изображений в WebP. Multipart содержит только `file`, успешный ответ по-прежнему `{url}`. Сервер хранит фактический тип содержимого внутри UserFile и проверяет его при привязке к решению; новые публичные поля ответа не добавляются. Клиент с опцией оригинала внедряется после backend с ее поддержкой. + +Изменение 1.1.0: добавлен необязательный `permissions.can_leave`, вычисляемый сервером для собственного выхода принятого участника. При отсутствии поля клиент считает его false. Существующие поля, URL и payload команд не меняются; C00 1.0.0 остается совместимым подмножеством. Капитан, сданная заявка и выключенный режим дают false. Источники: ТЗ «Решение вместо проекта в программах PROCOLLAB» и действующие правила ветки dev, проверенные на backend b7d9f57 и frontend bc1f961b. Старые API и обычные проекты сохраняют свои контракты. @@ -19,19 +63,23 @@ C00 фиксирует техническую основу. D00 v3 явно со Решение принадлежит одной программе. Формат формы — case_championship или hackathon. У программы одна общая форма; разные поля по кейсам не входят в v1. Кейс или задача представлены полем select с purpose:case, без новой сущности Case. Поля шаблона копируются в конфигурацию программы. -Создание доступно по текущим правилам участника и дедлайна, включая существующие административные исключения. Повторное создание капитаном возвращает его заявку с HTTP 200; новое — 201. До первого включения все изменяющие endpoints проверяют технический режим программы. Наличие серверного кода при выключенном режиме не дает создать решение. +Создание доступно по текущим правилам участника и дедлайна, включая существующие административные исключения. Повторное создание капитаном возвращает его заявку с HTTP 200; новое — 201. До первого включения все изменяющие endpoints проверяют технический режим программы. Наличие серверного кода при выключенном режиме не дает создать решение. Дополнительно новое создание требует solution_creation_enabled=True; default=False. Откат creation flag не меняет режим, текущую заявку, права или данные уже созданных работ. + +## Метаданные существующей страницы программы + +GET /programs/{id}/ дополнительно передает read-only `solution_storage_mode: legacy_project | solution` в member и unregistered вариантах. Это только выбор маршрута; поле не открывает описание, форму, состав, ответы или оценки. `current_application` сохраняет прежний Project DTO. Верхний экспертный вход и ссылка виджета ведут в `solutions-rating` только при явном `solution`, иначе в `projects-rating`. Старый клиент игнорирует добавочное поле; новый клиент при его отсутствии сохраняет старый путь. Rollout: backend → frontend, без миграций. Эта совместимая метаинформация существующего API сама по себе не добавляет операций решения и не изменяет существующие схемы C00. ## Модель хранения -| Объект | Данные и связи | -| --- | --- | -| ProgramSolution | id, program FK, captain FK User, name, version bigint, status draft/submitted, submitted_at nullable, submission_snapshot JSON nullable, created_at, updated_at | -| ProgramSolutionMember | solution FK, user FK, role nullable, specialization nullable, joined_at | -| SolutionInvitation | solution FK, recipient FK User, role nullable, specialization nullable, motivational_letter nullable, status pending/accepted/declined, version bigint, created_at, updated_at | -| ProgramSolutionAnswer | solution FK, field FK PartnerProgramField, value JSON; одна запись на пару | -| Конфигурация формы программы | template nullable до настройки; определения PartnerProgramField расширяются type, purpose, position, validation; revision считается по всей конфигурации | -| Профиль участника программы | Существующая регистрация сохраняется. Текущая заявка определяется по принятому составу или капитанству, не только по старому profile.project | -| UserFile | Существующий файл с первичным ключом link. Новый File ID не изобретается | +| Объект | Данные и связи | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| ProgramSolution | id, program FK, captain FK User, name, version bigint, status draft/submitted, submitted_at nullable, submission_snapshot JSON nullable, created_at, updated_at | +| ProgramSolutionMember | solution FK, user FK, role nullable, specialization nullable, joined_at | +| SolutionInvitation | solution FK, recipient FK User, role nullable, specialization nullable, motivational_letter nullable, status pending/accepted/declined, version bigint, created_at, updated_at | +| ProgramSolutionAnswer | solution FK, field FK PartnerProgramField, value JSON; одна запись на пару | +| Конфигурация формы программы | template nullable до настройки; определения PartnerProgramField расширяются type, purpose, position, validation; revision считается по всей конфигурации | +| Профиль участника программы | Существующая регистрация сохраняется. Текущая заявка определяется по принятому составу или капитанству, не только по старому profile.project | +| UserFile | Существующий файл с первичным ключом link. Новый File ID не изобретается | Капитан также имеет одну запись ProgramSolutionMember. При создании она добавляется в той же транзакции. При передаче капитанства меняется captain FK на уже принятого участника; прежний капитан остается участником. is_captain вычисляется сравнением user.id и captain_id. Дублировать капитана в численности нельзя. @@ -41,6 +89,8 @@ C00 фиксирует техническую основу. D00 v3 явно со Физически новые приглашения хранятся в SolutionInvitation, а старые Invite не получают фиктивный project FK. Общая бизнес policy используется через адаптер владельца. Операции отзыва сохраняют действующую семантику удаления: pending приглашение удаляется; статус revoked не добавляется. accepted и declined остаются историческими строками. +BE04 добавляет nullable JSON-колонку PartnerProgramField.solution_options для точного хранения вариантов, включая символ |. При null чтение использует прежний options; старые определения автоматически не переписываются. Новая форма сохраняется целиком через solution-form. Прежний project-form не изменяет определения нового конструктора, в том числе после технического выключения режима. + ## Ревизии ProgramSolution.version начинается с 1 и увеличивается при каждом успешном изменении содержимого, состава или приглашений и при сдаче. Изменение разрешений из-за времени или роли и изменение формы организатором само по себе версию решения не повышают. @@ -71,39 +121,39 @@ JWT Bearer и текущие правила аутентификации сох ## Endpoints -| Метод и путь | Назначение | -| --- | --- | -| GET /programs/{program_id}/solution-context/ | Режим программы, сроки, доступность создания и текущая заявка | -| GET /programs/{program_id}/solutions/ | Доступный пользователю список решений | -| POST /programs/{program_id}/solutions/ | Создать решение с начальным названием либо вернуть существующее | -| GET /programs/{program_id}/solutions/{solution_id}/ | Полная разрешенная заявка: team, form, answers, submission, permissions, readiness | -| PATCH /programs/{program_id}/solutions/{solution_id}/ | Частично сохранить название и ответы черновика | -| POST /programs/{program_id}/solutions/{solution_id}/submit/ | Сдать сохраненную заявку | -| DELETE /programs/{program_id}/solutions/{solution_id}/members/{user_id}/ | Капитан удаляет участника | -| POST /programs/{program_id}/solutions/{solution_id}/leave/ | Участник выходит | -| POST /programs/{program_id}/solutions/{solution_id}/captain/ | Передача капитанства | -| GET и POST /programs/{program_id}/solutions/{solution_id}/invitations/ | Капитан читает приглашения заявки или создает приглашение | -| GET /solution-invitations/ | Входящие приглашения текущего пользователя | -| GET /solution-invitations/{invitation_id}/ | Безопасное представление для капитана и получателя | -| PATCH и DELETE /solution-invitations/{invitation_id}/ | Изменить или отозвать pending приглашение капитаном | -| POST /solution-invitations/{invitation_id}/accept/ | Получатель принимает приглашение | -| POST /solution-invitations/{invitation_id}/decline/ | Получатель отклоняет приглашение | -| GET /programs/{program_id}/solution-form/ | Текущая форма, доступная участнику программы | -| GET и PUT /programs/{program_id}/management/solution-form/ | Организатор читает или атомарно меняет форму | -| GET /solution-form-templates/ | Прототипы двух шаблонов | -| POST /files/ | Существующая multipart загрузка, ответ 201 {url} | +| Метод и путь | Назначение | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| GET /programs/{program_id}/solution-context/ | Режим программы, сроки, доступность создания и текущая заявка | +| GET /programs/{program_id}/solutions/ | Доступный пользователю список решений | +| POST /programs/{program_id}/solutions/ | Создать решение с начальным названием либо вернуть существующее | +| GET /programs/{program_id}/solutions/{solution_id}/ | Полная разрешенная заявка: team, form, answers, submission, permissions, readiness | +| PATCH /programs/{program_id}/solutions/{solution_id}/ | Частично сохранить название и ответы черновика | +| POST /programs/{program_id}/solutions/{solution_id}/submit/ | Сдать сохраненную заявку | +| DELETE /programs/{program_id}/solutions/{solution_id}/members/{user_id}/ | Капитан удаляет участника | +| POST /programs/{program_id}/solutions/{solution_id}/leave/ | Участник выходит | +| POST /programs/{program_id}/solutions/{solution_id}/captain/ | Передача капитанства | +| GET и POST /programs/{program_id}/solutions/{solution_id}/invitations/ | Капитан читает приглашения заявки или создает приглашение | +| GET /solution-invitations/ | Входящие приглашения текущего пользователя | +| GET /solution-invitations/{invitation_id}/ | Безопасное представление для капитана и получателя | +| PATCH и DELETE /solution-invitations/{invitation_id}/ | Изменить или отозвать pending приглашение капитаном | +| POST /solution-invitations/{invitation_id}/accept/ | Получатель принимает приглашение | +| POST /solution-invitations/{invitation_id}/decline/ | Получатель отклоняет приглашение | +| GET /programs/{program_id}/solution-form/ | Текущая форма, доступная участнику программы | +| GET и PUT /programs/{program_id}/management/solution-form/ | Организатор читает или атомарно меняет форму | +| GET /solution-form-templates/ | Прототипы двух шаблонов | +| POST /files/ | Существующая multipart загрузка, ответ 201 {url} | Списки solutions используют search и submission=all/submitted/not_submitted. В неконкурсной программе submission, отличный от all, возвращает 400. Списки приглашений используют status=pending/accepted/declined, default pending. Общие списки и контекст сортируются детерминированно по created_at и ID согласно текущему интерфейсу, без случайного выбора первой заявки. Контекст существует и в legacy режиме. current_application — null либо ссылка с kind:solution и solution_id либо kind:legacy_project с project_id и program_link_id. Это discriminated union для навигации, а не новая сущность. Несогласованные несколько текущих заявок дают 409 ambiguous_application. -blocked_reason в контексте: null, already_has_application, legacy_mode, form_not_configured, not_program_member, submission_closed или already_in_program_team. Порядок выбора: текущая заявка, legacy режим, права, закрытая подача, неготовая форма. Детали участия в чужой команде не раскрываются постороннему. +blocked_reason в контексте: null, already_has_application, legacy_mode, form_not_configured, not_program_member, solution_creation_disabled, submission_closed или already_in_program_team. Порядок выбора: текущая заявка, legacy режим, права, выключенное создание, закрытая подача, неготовая форма. Детали участия в чужой команде не раскрываются постороннему. ## Чтение и полномочия Капитан и принятые участники читают заявку. Организатор, эксперт и прочие зрители получают доступ в пределах текущей политики программы, назначений и публикации работ. Это не дает прав изменения или сдачи за капитана. -permissions в Solution вычисляются для текущего пользователя: can_edit, can_manage_team, can_invite, can_submit. У участника и эксперта все изменяющие флаги false. can_submit означает, что капитану разрешена команда сдачи по состоянию и срокам; неполнота полей отражается отдельно в readiness. Это позволяет нажать действие и увидеть причины неготовности. +permissions в Solution вычисляются для текущего пользователя: can_edit, can_manage_team, can_invite, can_submit и can_leave. У участника и эксперта нет прав редактирования, управления командой, приглашения и сдачи. Принятый участник может получить can_leave для собственного выхода. can_submit означает, что капитану разрешена команда сдачи по состоянию и срокам; неполнота полей отражается отдельно в readiness. Это позволяет нажать действие и увидеть причины неготовности. readiness содержит is_ready и issues. Для неконкурсных заявок и зрителей вне своей команды возвращается null. Для своей команды выводятся актуальные требования без раскрытия чужих персональных данных. Разрешение на действие не вычисляется на фронте по роли или часам браузера. @@ -127,7 +177,7 @@ readiness содержит is_ready и issues. Для неконкурсных В ожидающих приглашениях сохраняются видимые действия изменения роли и отзыва с подтверждением. Прежнее описание об отсутствии видимой кнопки редактирования относилось к старому checkout и исправлено. После сдачи применяются ограничения C00: редактирование роли и принятие запрещены, отзыв и отклонение доступны. Получатель использует существующие входящие приглашения и принять/отклонить, ссылка адаптируется к Solution. -Схемы C00 для создания по recipient_user_id, изменения и отзыва уже поддерживают этот путь и не менялись. Nullable role на уровне API сохранен для совместимости с legacy моделью; UI требует заполненную нормализованную роль. Существующие проектные endpoints сохраняют работу; решения используют свой контракт. Ошибки показываются безопасным сообщением без raw backend body. Черновик заявки не отправляет приглашение автоматически. +В C00 1.16 создание по recipient_user_id требует заполненную role1–128 с непробельным символом; PATCH не очищает переданную role, но может менять другие поля без нее. Nullable role сохраняется в ответах и исторических записях: их чтение/принятие/отклонение/отзыв не требуют искусственного заполнения. UI отправляет нормализованную роль, сервер проверяет и сохраняет полученное значение. Существующие проектные endpoints сохраняют работу; решения используют свой контракт. Ошибки показываются безопасным сообщением без raw backend body. Черновик заявки не отправляет приглашение автоматически. Принятый состав представлен team.members. accepted_count равен количеству уникальных членов, включая капитана. pending_count — уникальные pending получатели. reserved_count — количество уникальных пользователей в объединении принятого состава и pending получателей; при штатных данных равно сумме двух счетчиков. @@ -145,13 +195,13 @@ remove и transfer возвращают MutationReceipt с новой solution_v Ответ представлен как {field_id,value}. Тип берется из определения поля, а не из переданного клиентом type: -| Тип | JSON value | -| --- | --- | -| text, textarea, url | string | -| select, radio | Точная строка из options | -| number | JSON number, включая 0 | -| checkbox | boolean, включая false | -| file | {url: URL существующего UserFile} | +| Тип | JSON value | +| ------------------- | --------------------------------- | +| text, textarea, url | string | +| select, radio | Точная строка из options | +| number | JSON number, включая 0 | +| checkbox | boolean, включая false | +| file | {url: URL существующего UserFile} | PATCH с value:null очищает ответ. Пустая строка и строка из пробелов также очищают текст, ссылку и выбор в черновике. В ответе GET очищенные поля отсутствуют в answers, не содержат null. Дубликат field_id в запросе отклоняется целиком. Сохраненный текст не меняется автоматически, кроме нормализации, предусмотренной текущим полем; проверка пустоты использует trim. @@ -207,16 +257,16 @@ PATCH и submit — две отдельные транзакции. Если sub Новый Error содержит обязательные code, detail, errors и meta. errors — массив {path,code,field_id,detail}; field_id:null для системной ошибки. meta несет current_version или current_form_revision только при наличии права чтения. -| HTTP | Коды | -| --- | --- | -| 400 | validation_error, invalid_answers, required_program_fields, case_required, case_unavailable, invalid_form | -| 401 | authentication_required | -| 403 | not_program_member, not_solution_captain, not_invite_recipient, permission_denied | -| 404 | not_found | -| 409 | solution_mode_unavailable, form_not_configured, version_conflict, form_revision_conflict, already_in_program_team, ambiguous_application | -| 409 | team_frozen, team_min_size, team_max_size, already_solution_member, duplicate_pending_invite, invite_already_processed | -| 409 | captain_cannot_leave, captain_cannot_be_removed, captain_already_selected, not_solution_member | -| 409 | submission_closed, already_submitted, not_competitive, field_in_use, template_locked | +| HTTP | Коды | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 400 | validation_error, invalid_answers, required_program_fields, case_required, case_unavailable, invalid_form | +| 401 | authentication_required | +| 403 | not_program_member, not_solution_captain, not_invite_recipient, permission_denied | +| 404 | not_found | +| 409 | solution_mode_unavailable, solution_creation_disabled, form_not_configured, version_conflict, form_revision_conflict, already_in_program_team, ambiguous_application | +| 409 | team_frozen, team_min_size, team_max_size, already_solution_member, duplicate_pending_invite, invite_already_processed | +| 409 | captain_cannot_leave, captain_cannot_be_removed, captain_already_selected, not_solution_member | +| 409 | submission_closed, already_submitted, not_competitive, field_in_use, template_locked, file_in_use | Это HTTP контракты новых endpoints. Существующие статусы старых /projects/, /invites/ и submit связи не меняются. Повторное действие с удаленным приглашением дает 404. Повтор accept/decline сохраненной обработанной строки — 409 invite_already_processed. @@ -234,19 +284,19 @@ Legacy порядок Project → Program → program links не меняетс ## Карта потребителей -| Текущий участок | Изменение | Задача | -| --- | --- | --- | -| Project и PartnerProgramProject, profile.project | Владение данными переносится в ProgramSolution; профиль регистрации сохраняется | BE01, BE08, FE01 | -| Collaborator и projects.team_policy/team_service | Общая policy, новый владелец участников; счетчики без дубля капитана | BE02, FE04 | -| Invite и текущий inbox | Новые приглашения через SolutionInvitation и /solution-invitations/; старые /invites/ работают прежним способом | BE03, BE08, FE05 | -| PartnerProgramField и program field values | Definitions переиспользуются; ответы нового решения — typed JSON | BE04, BE05, FE02, FE03 | -| files.UserFile и /files/ | Текущий upload, проверка привязки и защита использования после сдачи | BE05, BE06, FE03 | -| Link-scoped submit | Новая команда submit с теми же правилами и снимком | BE06, FE06 | -| ProjectExpertAssignment, ProjectScore и результаты | Привязка к решению без смены критериев и формул | BE07, FE07 | -| Management, role widgets, case analytics, exports | Чтение решения; признак материала через purpose; нет двойного учета | BE08, BE09, FE08 | -| Уведомления и глубокие ссылки | Явная ссылка kind:solution с program_id и solution_id | BE08, FE05, FE08 | -| История программ | legacy adapter и таблица old link → new solution | BE10 | -| Чаты и вакансии программного Project | Не создаются автоматически для нового решения; сохраненный доступ к существующим связанным объектам проверяется до переноса | C00 карта, BE08, BE10 | +| Текущий участок | Изменение | Задача | +| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| Project и PartnerProgramProject, profile.project | Владение данными переносится в ProgramSolution; профиль регистрации сохраняется | BE01, BE08, FE01 | +| Collaborator и projects.team_policy/team_service | Общая policy, новый владелец участников; счетчики без дубля капитана | BE02, FE04 | +| Invite и текущий inbox | Новые приглашения через SolutionInvitation и /solution-invitations/; старые /invites/ работают прежним способом | BE03, BE08, FE05 | +| PartnerProgramField и program field values | Definitions переиспользуются; ответы нового решения — typed JSON | BE04, BE05, FE02, FE03 | +| files.UserFile и /files/ | Текущий upload, проверка привязки и защита использования после сдачи | BE05, BE06, FE03 | +| Link-scoped submit | Новая команда submit с теми же правилами и снимком | BE06, FE06 | +| ProjectExpertAssignment, ProjectScore и результаты | Привязка к решению без смены критериев и формул | BE07, FE07 | +| Management, role widgets, case analytics, exports | Чтение решения; признак материала через purpose; нет двойного учета | BE08, BE09, FE08 | +| Уведомления и глубокие ссылки | Явная ссылка kind:solution с program_id и solution_id | BE08, FE05, FE08 | +| История программ | legacy adapter и таблица old link → new solution | BE10 | +| Чаты и вакансии программного Project | Не создаются автоматически для нового решения; сохраненный доступ к существующим связанным объектам проверяется до переноса | C00 карта, BE08, BE10 | Чаты и вакансии не являются зависимостью формы, приглашения и submit нового решения. Полный перенос их функциональности на новую сущность оформляется отдельно; существующие объекты старых программ не удаляются и не теряют маршрут. Если пилотной программе нужны эти функции, их готовность проверяется до выбора этой программы для REL01. @@ -269,3 +319,7 @@ python validate_contract.py ``` Скрипт проверяет корректность OpenAPI, все локальные refs и схемы, примеры request/response, уникальность operationId, базовые инварианты состава и snapshot. Реальные ответы BE01 дополнительно проверяются JSON Schema в `partner_programs.tests.test_solution_foundation`. + +## Изменение 1.14 Первая сдача организатором + +Два отдельных POST management/solutions/actions/preview и apply сохраняют существующее organizer/admin исключение поздней первой сдачи. action=submit и 1–10000 safe Solution IDs; signed preview на 10 минут привязан к actor/program/IDs и сохраненным материалам/составу/форме/policy/files. Предпросмотр не пишет, apply повторяет проверки атомарно и создает обычный accepted-only snapshot. Уже сданные данные/дата и captain deadline неизменны. Nullable token/can_apply/counts/per-solution Issue[] и changed_count/already_submitted_count зафиксированы. Ошибки C00/private,no-store. 125 schemas/47 operations/185 scenarios; прежние 120/45/161 структурно неизменны. Миграций нет. UI ожидает D02; post-submission case/detach/removal сюда не входят. diff --git a/docs/contracts/program-solutions-v1/examples.json b/docs/contracts/program-solutions-v1/examples.json index 4d3a86d5..e0d4745c 100644 --- a/docs/contracts/program-solutions-v1/examples.json +++ b/docs/contracts/program-solutions-v1/examples.json @@ -1,5 +1,5 @@ { - "contract_version": "1.0.0", + "contract_version": "1.21.0", "description": "Независимые примеры, не последовательность операций; фиктивные данные.", "scenarios": [ { @@ -224,7 +224,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": false, @@ -424,7 +425,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": true, @@ -617,7 +619,8 @@ "can_edit": false, "can_manage_team": false, "can_invite": false, - "can_submit": false + "can_submit": false, + "can_leave": false }, "readiness": { "is_ready": true, @@ -804,7 +807,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": false + "can_submit": false, + "can_leave": false }, "readiness": null } @@ -988,7 +992,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": false + "can_submit": false, + "can_leave": false }, "readiness": { "is_ready": false, @@ -1154,7 +1159,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": false, @@ -1367,7 +1373,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": false, @@ -1587,7 +1594,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": true, @@ -1784,7 +1792,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": false, @@ -1988,7 +1997,8 @@ "can_edit": false, "can_manage_team": false, "can_invite": false, - "can_submit": false + "can_submit": false, + "can_leave": false }, "readiness": { "is_ready": true, @@ -2156,7 +2166,8 @@ "status": 409, "request": { "expected_solution_version": 8, - "recipient_user_id": 9 + "recipient_user_id": 9, + "role": "Аналитик" }, "response": { "code": "duplicate_pending_invite", @@ -2171,7 +2182,8 @@ "status": 409, "request": { "expected_solution_version": 8, - "recipient_user_id": 9 + "recipient_user_id": 9, + "role": "Аналитик" }, "response": { "code": "team_max_size", @@ -3218,7 +3230,8 @@ "can_edit": true, "can_manage_team": true, "can_invite": true, - "can_submit": true + "can_submit": true, + "can_leave": false }, "readiness": { "is_ready": true, @@ -3623,7 +3636,8 @@ "status": 409, "request": { "expected_solution_version": 10, - "recipient_user_id": 9 + "recipient_user_id": 9, + "role": "Аналитик" }, "response": { "code": "team_frozen", @@ -3811,7 +3825,8 @@ "can_edit": false, "can_manage_team": false, "can_invite": false, - "can_submit": false + "can_submit": false, + "can_leave": true }, "readiness": { "is_ready": false, @@ -4011,7 +4026,8 @@ "can_edit": false, "can_manage_team": false, "can_invite": false, - "can_submit": false + "can_submit": false, + "can_leave": false }, "readiness": null } @@ -4031,6 +4047,8604 @@ "blocked_reason": null, "current_application": null } + }, + { + "name": "evaluation_list", + "operation_id": "getSolutionRatings", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "case_name": "Цифровой сервис", + "assigned": true, + "is_completed": false, + "can_rate": true, + "blocked_reason": null + } + ] + } + }, + { + "name": "evaluation_frame", + "operation_id": "getSolutionRating", + "status": 200, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": null + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": null + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } + }, + { + "name": "evaluation_saved", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5" + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } + }, + { + "name": "evaluation_criteria_changed", + "operation_id": "rateSolution", + "status": 409, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + }, + "response": { + "code": "criteria_changed", + "detail": "Критерии изменились. Обновите форму оценки.", + "errors": [], + "meta": { + "current_criteria_revision": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + } + }, + { + "name": "solution_assignment_options", + "operation_id": "getSolutionExpertAssignments", + "status": 200, + "response": { + "program_id": 12, + "expert_id": 31, + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "solutions": [ + { + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "submitted": true, + "assigned": false, + "protected": false, + "is_completed": false, + "own_team": false, + "eligible": true + } + ] + } + }, + { + "name": "solution_assignments_saved", + "operation_id": "saveSolutionExpertAssignments", + "status": 200, + "request": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "solution_ids": [ + 301 + ] + }, + "response": { + "program_id": 12, + "expert_id": 31, + "revision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "solutions": [ + { + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "submitted": true, + "assigned": true, + "protected": false, + "is_completed": false, + "own_team": false, + "eligible": true + } + ] + } + }, + { + "name": "evaluation_invalid_value", + "operation_id": "rateSolution", + "status": 400, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "NaN" + } + ] + }, + "response": { + "code": "invalid_scores", + "detail": "Проверьте критерии оценки.", + "errors": [ + { + "path": "scores.21.value", + "code": "invalid_value", + "field_id": null, + "criterion_id": 21, + "detail": "Укажите конечное число." + } + ], + "meta": {} + } + }, + { + "name": "solution_allocation_preview", + "operation_id": "previewSolutionAllocation", + "status": 200, + "request": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "expert_ids": [ + 31 + ] + }, + "response": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "plan_token": "signed-local-example", + "assignments": [ + { + "solution_id": 301, + "solution_name": "Решение команды", + "expert_id": 31, + "expert_name": "Эксперт программы", + "case_name": "Цифровой сервис" + } + ], + "shortages": [], + "can_reroll": false, + "load": [ + { + "expert_id": 31, + "name": "Эксперт программы", + "before": 0, + "after": 1, + "assigned_before": 0, + "assigned_after": 1 + } + ] + } + }, + { + "name": "solution_allocation_applied", + "operation_id": "applySolutionAllocation", + "status": 200, + "request": { + "plan_token": "signed-local-example" + }, + "response": { + "program_id": 12, + "revision": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "created": 1 + } + }, + { + "name": "solution_quota_preview", + "operation_id": "previewSolutionQuota", + "status": 200, + "response": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "required_evaluators": 2, + "submitted_solutions": 1, + "solutions_missing_experts": 1, + "ready_before": 0, + "ready_after": 0, + "missing_assignments": 1, + "surplus_assignments": 0 + } + }, + { + "name": "solution_quota_saved", + "operation_id": "saveSolutionQuota", + "status": 200, + "request": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "required_evaluators": 2 + }, + "response": { + "revision": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "required_evaluators": 2, + "submitted_solutions": 1, + "solutions_missing_experts": 1, + "ready_before": 0, + "ready_after": 0, + "missing_assignments": 1, + "surplus_assignments": 0 + } + }, + { + "name": "solution_results", + "operation_id": "getSolutionResults", + "status": 200, + "response": { + "program_id": 12, + "program_name": "Кейс-чемпионат PROCOLLAB", + "generated_at": "2026-10-24T20:00:00Z", + "required_evaluators": 2, + "maximum": 15, + "has_numeric_criteria": true, + "case_options": [ + "Цифровой сервис" + ], + "groups": [ + { + "case_name": "Цифровой сервис", + "solutions": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_id": 301, + "name": "Сервис для студентов", + "case_name": "Цифровой сервис", + "completed": 1, + "required": 2, + "evaluation_complete": false, + "ready": false, + "score": 4.5, + "place": null, + "averages": { + "21": 0, + "22": 4.5 + }, + "experts": [ + { + "user_id": 91, + "name": "Эксперт программы", + "email": "expert@local.example", + "avatar": null, + "complete": true, + "total": 4.5, + "values": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + } + ] + } + ] + } + ], + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10 + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5 + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null + } + ] + } + }, + { + "name": "mixed_cabinet_applications", + "operation_id": "getCabinetApplications", + "status": 200, + "response": { + "count": 2, + "limit": 20, + "offset": 0, + "pending": 2, + "completed": 0, + "unavailable": 0, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "name": "Работа команды", + "solution_id": 301 + }, + "assignment_id": 7, + "program": { + "id": 12, + "name": "Программа" + }, + "deadline": null, + "case_name": null, + "is_completed": false, + "can_rate": true, + "blocked_reason": null + }, + { + "application": { + "kind": "legacy_project", + "program_id": 13, + "name": "Работа команды", + "project_id": 301, + "program_link_id": 44 + }, + "assignment_id": 7, + "program": { + "id": 13, + "name": "Программа" + }, + "deadline": null, + "case_name": null, + "is_completed": false, + "can_rate": true, + "blocked_reason": null + } + ] + } + }, + { + "name": "solution_results_export_invalid_scope", + "operation_id": "exportSolutionResults", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "scope", + "code": "invalid_choice", + "detail": "Выберите current или all.", + "field_id": null + } + ], + "meta": {} + } + }, + { + "name": "solution_rating_long_comment", + "operation_id": "rateSolution", + "status": 200, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ] + }, + "response": { + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "id": 301, + "program_id": 12, + "version": 10, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "criteria": [ + { + "id": 21, + "name": "Практическая ценность", + "description": "Польза аудитории", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 22, + "name": "Проработанность", + "description": null, + "type": "float", + "min_value": 0, + "max_value": 5, + "value": "4.5" + }, + { + "id": 23, + "name": "Показан прототип", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 24, + "name": "Рекомендация", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "АААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААААА" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 2, + "deadline": "2026-10-30T18:00:00Z" + } + }, + { + "name": "solution_rating_counts_before_status_and_page", + "operation_id": "getSolutionRatings", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "case_name": "Цифровой сервис", + "assigned": true, + "is_completed": false, + "can_rate": true, + "blocked_reason": null + } + ], + "evaluation_counts": { + "pending": 1, + "completed": 0 + }, + "case_options": [ + "Цифровой сервис" + ] + } + }, + { + "name": "solution_widget_participant_snapshot", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "participant", + "is_competitive": true, + "storage_mode": "solution", + "participant": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "case_provided": true, + "case_name": "Цифровой сервис", + "stage": "evaluated", + "submission_open": false + } + } + }, + { + "name": "solution_widget_participant_draft_without_case", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "participant", + "is_competitive": true, + "storage_mode": "solution", + "participant": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "case_provided": true, + "case_name": null, + "stage": "not_submitted", + "submission_open": true + } + } + }, + { + "name": "solution_widget_participant_noncompetitive", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "participant", + "is_competitive": false, + "storage_mode": "solution", + "participant": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "case_provided": true, + "case_name": "Цифровой сервис", + "stage": "not_applicable", + "submission_open": false + } + } + }, + { + "name": "solution_widget_organizer", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "organizer", + "is_competitive": true, + "storage_mode": "solution", + "organizer": { + "participants": 5, + "solutions": 2, + "submitted_solutions": 1, + "participants_without_solution": 2 + } + } + }, + { + "name": "solution_widget_organizer_noncompetitive", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "organizer", + "is_competitive": false, + "storage_mode": "solution", + "organizer": { + "participants": 5, + "solutions": 2, + "submitted_solutions": null, + "participants_without_solution": 2 + } + } + }, + { + "name": "solution_widget_expert_zero_complete", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "expert", + "is_competitive": true, + "storage_mode": "solution", + "expert": { + "mode": "distributed", + "assigned": 1, + "remaining": 0, + "evaluation_ends": "2026-10-24T18:00:00Z" + } + } + }, + { + "name": "solution_widget_expert_open", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "expert", + "is_competitive": true, + "storage_mode": "solution", + "expert": { + "mode": "open", + "assigned": null, + "remaining": null, + "evaluation_ends": "2026-10-24T18:00:00Z" + } + } + }, + { + "name": "solution_widget_expert_empty", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "expert", + "is_competitive": true, + "storage_mode": "solution", + "expert": { + "mode": "distributed", + "assigned": 0, + "remaining": 0, + "evaluation_ends": "2026-10-24T18:00:00Z" + } + } + }, + { + "name": "legacy_widget_participant", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "participant", + "is_competitive": true, + "participant": { + "case_provided": true, + "case_name": "Цифровой сервис", + "stage": "evaluated", + "submission_open": false, + "participant_project": { + "id": 55, + "name": "Исторический проект", + "program_link_id": 65 + } + } + } + }, + { + "name": "legacy_widget_organizer", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "organizer", + "is_competitive": true, + "organizer": { + "participants": 5, + "projects": 2, + "submitted_solutions": 1, + "participants_without_project": 2 + } + } + }, + { + "name": "legacy_widget_expert", + "operation_id": "getProgramRoleWidget", + "status": 200, + "response": { + "program_id": 12, + "role": "expert", + "is_competitive": true, + "expert": { + "mode": "distributed", + "assigned": 1, + "remaining": 0, + "evaluation_ends": "2026-10-24T18:00:00Z" + } + } + }, + { + "name": "widget_forbidden", + "operation_id": "getProgramRoleWidget", + "status": 403, + "response": { + "detail": "Аналитика доступна только участникам этой программы." + } + }, + { + "name": "widget_ambiguous_membership", + "operation_id": "getProgramRoleWidget", + "status": 409, + "response": { + "detail": "Связи заявки требуют проверки организатором." + } + }, + { + "name": "solution_case_analytics_competitive", + "operation_id": "getSolutionCaseAnalytics", + "status": 200, + "response": { + "program_id": 12, + "configured": true, + "submission_applicable": true, + "items": [ + { + "name": "Экология", + "participants_total": 3, + "solutions_total": 2, + "not_submitted": 1, + "submitted": 1 + }, + { + "name": "Технологии", + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + ], + "without_case": { + "participants_total": 1, + "solutions_total": 1, + "not_submitted": 1, + "submitted": 0 + } + } + }, + { + "name": "solution_case_analytics_noncompetitive", + "operation_id": "getSolutionCaseAnalytics", + "status": 200, + "response": { + "program_id": 12, + "configured": true, + "submission_applicable": false, + "items": [ + { + "name": "Экология", + "participants_total": 3, + "solutions_total": 2, + "not_submitted": null, + "submitted": null + }, + { + "name": "Технологии", + "participants_total": 0, + "solutions_total": 0, + "not_submitted": null, + "submitted": null + } + ], + "without_case": { + "participants_total": 1, + "solutions_total": 1, + "not_submitted": null, + "submitted": null + } + } + }, + { + "name": "solution_case_analytics_unconfigured", + "operation_id": "getSolutionCaseAnalytics", + "status": 200, + "response": { + "program_id": 12, + "configured": false, + "submission_applicable": true, + "items": [], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + } + }, + { + "name": "solution_case_analytics_forbidden", + "operation_id": "getSolutionCaseAnalytics", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Аналитика доступна организатору программы.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_case_analytics_anonymous", + "operation_id": "getSolutionCaseAnalytics", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Требуется авторизация.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_case_analytics_missing", + "operation_id": "getSolutionCaseAnalytics", + "status": 404, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_case_analytics_legacy", + "operation_id": "getSolutionCaseAnalytics", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_case_analytics_unexpected_query", + "operation_id": "getSolutionCaseAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_solution_analytics_distributed", + "operation_id": "getSolutionManagerAnalytics", + "status": 200, + "response": { + "program_id": 12, + "summary": { + "participants": { + "total": 3 + }, + "solutions": { + "total": 1 + }, + "experts": { + "total": 1 + }, + "participant_regions": { + "total": 1, + "items": [ + { + "name": "Москва", + "count": 3 + } + ] + } + }, + "participant_funnel": { + "registrations": 3, + "unique_participants": 3, + "visited_participants": 2, + "onboarded_participants": 2, + "with_team": 2, + "captains": 1, + "submitted_captains": 1 + }, + "solution_funnel": { + "created": 1, + "submission_applicable": true, + "not_submitted": 0, + "submitted": 1, + "evaluated": 0 + }, + "evaluation_status": { + "applicable": true, + "mode": "distributed", + "required_evaluators": 2, + "max_evaluations_per_solution": 3, + "assignments": { + "total": 1, + "pending": 1, + "evaluated": 0 + }, + "solutions": { + "submitted": 1, + "awaiting_evaluation": 1, + "partially_evaluated": 0, + "evaluated": 0 + } + }, + "attention": { + "participants_without_visit": 1, + "participants_without_onboarding": 1, + "participants_without_team": 1, + "solutions_awaiting_evaluation": 1, + "solutions_not_submitted": 0, + "delayed_experts": { + "total": 0, + "items": [] + } + }, + "activity": [ + { + "date": "2026-10-24", + "registrations": 3, + "submitted_solutions": 1 + } + ], + "cases": { + "program_id": 12, + "configured": true, + "submission_applicable": true, + "items": [ + { + "name": "Экология", + "participants_total": 2, + "solutions_total": 1, + "not_submitted": 0, + "submitted": 1 + } + ], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + }, + "field_coverage": [ + { + "field_id": 9, + "label": "Презентация", + "type": "file", + "purpose": "presentation", + "applicable": 1, + "answered": 0, + "required_applicable": 0, + "required_unanswered": 0 + } + ] + } + }, + { + "name": "manager_solution_analytics_open", + "operation_id": "getSolutionManagerAnalytics", + "status": 200, + "response": { + "program_id": 12, + "summary": { + "participants": { + "total": 3 + }, + "solutions": { + "total": 1 + }, + "experts": { + "total": 1 + }, + "participant_regions": { + "total": 1, + "items": [ + { + "name": "Москва", + "count": 3 + } + ] + } + }, + "participant_funnel": { + "registrations": 3, + "unique_participants": 3, + "visited_participants": 2, + "onboarded_participants": 2, + "with_team": 2, + "captains": 1, + "submitted_captains": 1 + }, + "solution_funnel": { + "created": 1, + "submission_applicable": true, + "not_submitted": 0, + "submitted": 1, + "evaluated": 0 + }, + "evaluation_status": { + "applicable": true, + "mode": "open", + "required_evaluators": 2, + "max_evaluations_per_solution": 3, + "assignments": null, + "solutions": { + "submitted": 1, + "awaiting_evaluation": 1, + "partially_evaluated": 0, + "evaluated": 0 + } + }, + "attention": { + "participants_without_visit": 1, + "participants_without_onboarding": 1, + "participants_without_team": 1, + "solutions_awaiting_evaluation": 1, + "solutions_not_submitted": 0, + "delayed_experts": { + "total": 0, + "items": [] + } + }, + "activity": [ + { + "date": "2026-10-24", + "registrations": 3, + "submitted_solutions": 1 + } + ], + "cases": { + "program_id": 12, + "configured": true, + "submission_applicable": true, + "items": [ + { + "name": "Экология", + "participants_total": 2, + "solutions_total": 1, + "not_submitted": 0, + "submitted": 1 + } + ], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + }, + "field_coverage": [ + { + "field_id": 9, + "label": "Презентация", + "type": "file", + "purpose": "presentation", + "applicable": 1, + "answered": 0, + "required_applicable": 0, + "required_unanswered": 0 + } + ] + } + }, + { + "name": "manager_solution_analytics_noncompetitive", + "operation_id": "getSolutionManagerAnalytics", + "status": 200, + "response": { + "program_id": 12, + "summary": { + "participants": { + "total": 3 + }, + "solutions": { + "total": 1 + }, + "experts": { + "total": 1 + }, + "participant_regions": { + "total": 1, + "items": [ + { + "name": "Москва", + "count": 3 + } + ] + } + }, + "participant_funnel": { + "registrations": 3, + "unique_participants": 3, + "visited_participants": 2, + "onboarded_participants": 2, + "with_team": 2, + "captains": 1, + "submitted_captains": null + }, + "solution_funnel": { + "created": 1, + "submission_applicable": false, + "not_submitted": null, + "submitted": null, + "evaluated": null + }, + "evaluation_status": { + "applicable": false, + "mode": "distributed", + "required_evaluators": 2, + "max_evaluations_per_solution": 3, + "assignments": null, + "solutions": null + }, + "attention": { + "participants_without_visit": 1, + "participants_without_onboarding": 1, + "participants_without_team": 1, + "solutions_awaiting_evaluation": null, + "solutions_not_submitted": null, + "delayed_experts": { + "total": 0, + "items": [] + } + }, + "activity": [ + { + "date": "2026-10-24", + "registrations": 3, + "submitted_solutions": null + } + ], + "cases": { + "program_id": 12, + "configured": true, + "submission_applicable": false, + "items": [ + { + "name": "Экология", + "participants_total": 2, + "solutions_total": 1, + "not_submitted": null, + "submitted": null + } + ], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": null, + "submitted": null + } + }, + "field_coverage": [ + { + "field_id": 9, + "label": "Презентация", + "type": "file", + "purpose": "presentation", + "applicable": 1, + "answered": 0, + "required_applicable": 0, + "required_unanswered": 0 + } + ] + } + }, + { + "name": "manager_solution_assignments_pending", + "operation_id": "getSolutionManagerAssignments", + "status": 200, + "response": { + "applicable": true, + "mode": "distributed", + "results": [ + { + "assignment_id": 801, + "expert": { + "expert_id": 40, + "user_id": 71, + "first_name": "Иван", + "last_name": "Тест", + "full_name": "Иван Тест", + "avatar": null + }, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "status": "pending", + "assigned_at": "2026-10-25T00:00:00Z", + "submitted": true, + "submitted_at": "2026-10-24T00:00:00Z", + "waiting_since": "2026-10-25T00:00:00Z", + "waiting_seconds": 3600 + } + ] + } + }, + { + "name": "manager_solution_assignments_completed", + "operation_id": "getSolutionManagerAssignments", + "status": 200, + "response": { + "applicable": true, + "mode": "distributed", + "results": [ + { + "assignment_id": 801, + "expert": { + "expert_id": 40, + "user_id": 71, + "first_name": "Иван", + "last_name": "Тест", + "full_name": "Иван Тест", + "avatar": null + }, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "status": "completed", + "assigned_at": "2026-10-25T00:00:00Z", + "submitted": true, + "submitted_at": "2026-10-24T00:00:00Z", + "waiting_since": null, + "waiting_seconds": null + } + ] + } + }, + { + "name": "manager_solution_assignment_zero_false_comment", + "operation_id": "getSolutionManagerAssignment", + "status": 200, + "response": { + "assignment_id": 801, + "expert": { + "expert_id": 40, + "user_id": 71, + "first_name": "Иван", + "last_name": "Тест", + "full_name": "Иван Тест", + "avatar": null + }, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "status": "completed", + "assigned_at": "2026-10-25T00:00:00Z", + "submitted": true, + "submitted_at": "2026-10-24T00:00:00Z", + "waiting_since": null, + "waiting_seconds": null, + "scores": [ + { + "criterion_id": 7, + "name": "Оценка", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "is_scored": true + }, + { + "criterion_id": 8, + "name": "Условие", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False", + "is_scored": true + }, + { + "criterion_id": 9, + "name": "Комментарий", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "", + "is_scored": true + } + ] + } + }, + { + "name": "manager_solution_list_awaiting", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "submission_applicable": true, + "mode": "distributed", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "captain": { + "user_id": 5, + "full_name": "Капитан Тест", + "avatar": null + }, + "case_name": "Экология", + "submission_status": "submitted", + "evaluation_status": "awaiting_evaluation", + "created_at": "2026-10-23T00:00:00Z", + "submitted_at": "2026-10-24T00:00:00Z", + "assignments_total": 1, + "assignments_completed": 0, + "waiting_reason": "no_completed_evaluations" + } + ] + } + }, + { + "name": "manager_solution_list_empty", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "submission_applicable": false, + "mode": "open", + "results": [] + } + }, + { + "name": "getSolutionManagerAnalytics_forbidden", + "operation_id": "getSolutionManagerAnalytics", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAnalytics_anonymous", + "operation_id": "getSolutionManagerAnalytics", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAnalytics_missing", + "operation_id": "getSolutionManagerAnalytics", + "status": 404, + "response": { + "code": "not_found", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAnalytics_legacy", + "operation_id": "getSolutionManagerAnalytics", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAnalytics_invalid", + "operation_id": "getSolutionManagerAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignments_forbidden", + "operation_id": "getSolutionManagerAssignments", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignments_anonymous", + "operation_id": "getSolutionManagerAssignments", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignments_missing", + "operation_id": "getSolutionManagerAssignments", + "status": 404, + "response": { + "code": "not_found", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignments_legacy", + "operation_id": "getSolutionManagerAssignments", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignments_invalid", + "operation_id": "getSolutionManagerAssignments", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignment_forbidden", + "operation_id": "getSolutionManagerAssignment", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignment_anonymous", + "operation_id": "getSolutionManagerAssignment", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignment_missing", + "operation_id": "getSolutionManagerAssignment", + "status": 404, + "response": { + "code": "not_found", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignment_legacy", + "operation_id": "getSolutionManagerAssignment", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerAssignment_invalid", + "operation_id": "getSolutionManagerAssignment", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerSolutions_forbidden", + "operation_id": "getSolutionManagerSolutions", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerSolutions_anonymous", + "operation_id": "getSolutionManagerSolutions", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerSolutions_missing", + "operation_id": "getSolutionManagerSolutions", + "status": 404, + "response": { + "code": "not_found", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerSolutions_legacy", + "operation_id": "getSolutionManagerSolutions", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "getSolutionManagerSolutions_invalid", + "operation_id": "getSolutionManagerSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "exportSolutionProgramAnalytics_forbidden", + "operation_id": "exportSolutionProgramAnalytics", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "exportSolutionProgramAnalytics_anonymous", + "operation_id": "exportSolutionProgramAnalytics", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "exportSolutionProgramAnalytics_missing", + "operation_id": "exportSolutionProgramAnalytics", + "status": 404, + "response": { + "code": "not_found", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "exportSolutionProgramAnalytics_legacy", + "operation_id": "exportSolutionProgramAnalytics", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "exportSolutionProgramAnalytics_invalid", + "operation_id": "exportSolutionProgramAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Запрос недоступен.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_selected", + "operation_id": "previewSolutionExpertReminder", + "status": 200, + "response": { + "token": "opaque-signed-example-token", + "selected_count": 3, + "recipient_count": 1, + "without_assignments_count": 1, + "unavailable_count": 0, + "preview_text": "Эксперт, здравствуйте! В программе остались незавершенные оценки решений.", + "recipients": [ + { + "user_id": 27, + "full_name": "Анна Петрова" + } + ], + "expires_in": 900 + }, + "request": { + "selection": { + "mode": "selected", + "ids": [ + 301, + 302, + 303 + ] + } + } + }, + { + "name": "solution_reminder_preview_all_search_excluded", + "operation_id": "previewSolutionExpertReminder", + "status": 200, + "response": { + "token": "opaque-signed-example-token", + "selected_count": 2, + "recipient_count": 1, + "without_assignments_count": 0, + "unavailable_count": 0, + "preview_text": "Эксперт, здравствуйте! В программе остались незавершенные оценки решений.", + "recipients": [ + { + "user_id": 27, + "full_name": "Анна Петрова" + } + ], + "expires_in": 900 + }, + "request": { + "search": "Сервис", + "selection": { + "mode": "all", + "excluded_ids": [ + 304 + ] + } + } + }, + { + "name": "solution_reminder_preview_no_assignments", + "operation_id": "previewSolutionExpertReminder", + "status": 200, + "response": { + "token": null, + "selected_count": 1, + "recipient_count": 0, + "without_assignments_count": 1, + "unavailable_count": 0, + "preview_text": "", + "recipients": [], + "expires_in": 900 + }, + "request": { + "selection": { + "mode": "selected", + "ids": [ + 303 + ] + } + } + }, + { + "name": "solution_reminder_preview_missing_email", + "operation_id": "previewSolutionExpertReminder", + "status": 200, + "response": { + "token": null, + "selected_count": 1, + "recipient_count": 0, + "without_assignments_count": 0, + "unavailable_count": 1, + "preview_text": "", + "recipients": [], + "expires_in": 900 + }, + "request": { + "selection": { + "mode": "selected", + "ids": [ + 302 + ] + } + } + }, + { + "name": "solution_reminder_preview_no_auth", + "operation_id": "previewSolutionExpertReminder", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_forbidden", + "operation_id": "previewSolutionExpertReminder", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_not_found", + "operation_id": "previewSolutionExpertReminder", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_legacy", + "operation_id": "previewSolutionExpertReminder", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_not_applicable", + "operation_id": "previewSolutionExpertReminder", + "status": 409, + "response": { + "code": "reminders_not_applicable", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_preview_invalid_selection", + "operation_id": "previewSolutionExpertReminder", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_reminder_confirm", + "operation_id": "confirmSolutionExpertReminder", + "status": 200, + "response": { + "created_count": 1, + "skipped_count": 0 + }, + "request": { + "token": "opaque-signed-example-token" + } + }, + { + "name": "solution_reminder_confirm_idempotent", + "operation_id": "confirmSolutionExpertReminder", + "status": 200, + "response": { + "created_count": 1, + "skipped_count": 0 + }, + "request": { + "token": "opaque-signed-example-token" + } + }, + { + "name": "solution_reminder_confirm_completed_before_confirm", + "operation_id": "confirmSolutionExpertReminder", + "status": 200, + "response": { + "created_count": 0, + "skipped_count": 1 + }, + "request": { + "token": "opaque-signed-example-token" + } + }, + { + "name": "solution_reminder_confirm_bad_token", + "operation_id": "confirmSolutionExpertReminder", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + }, + "request": { + "token": "bad-token" + } + }, + { + "name": "solution_reminder_confirm_cross_actor", + "operation_id": "confirmSolutionExpertReminder", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + }, + "request": { + "token": "another-actor-token" + } + }, + { + "name": "published_solutions_list", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Прототип сервиса" + }, + "published_at": "2026-10-25T06:00:00Z", + "captain": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + } + }, + { + "name": "published_solutions_empty", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 0, + "next": null, + "previous": null, + "results": [] + } + }, + { + "name": "published_solutions_search_page", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 25, + "next": "https://local.test/programs/14/published-solutions/?limit=1&offset=1", + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Прототип сервиса" + }, + "published_at": "2026-10-25T06:00:00Z", + "captain": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + } + }, + { + "name": "published_solution_zero_false_file", + "operation_id": "getPublishedSolution", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Прототип сервиса" + }, + "published_at": "2026-10-25T06:00:00Z", + "fields": [ + { + "id": 701, + "type": "number", + "purpose": "other", + "label": "Размер пилота", + "help_text": "", + "required": false, + "position": 0, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 702, + "type": "checkbox", + "purpose": "other", + "label": "Разрешение на демонстрацию", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 703, + "type": "file", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 701, + "value": 0 + }, + { + "field_id": 702, + "value": false + }, + { + "field_id": 703, + "value": { + "url": "https://cdn.example.org/solution/materials.zip" + } + } + ], + "team": { + "members": [ + { + "user": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "role": "Капитан", + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + } + }, + { + "name": "published_solution_saved_partial_without_submit", + "operation_id": "getPublishedSolution", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Прототип сервиса" + }, + "published_at": "2026-10-25T06:00:00Z", + "fields": [ + { + "id": 701, + "type": "number", + "purpose": "other", + "label": "Размер пилота", + "help_text": "", + "required": false, + "position": 0, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 702, + "type": "checkbox", + "purpose": "other", + "label": "Разрешение на демонстрацию", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 703, + "type": "file", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "role": "Капитан", + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + } + }, + { + "name": "published_solution_empty_form_captain_only", + "operation_id": "getPublishedSolution", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Прототип сервиса" + }, + "published_at": "2026-10-25T06:00:00Z", + "fields": [], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "role": "Капитан", + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + } + }, + { + "name": "published_solution_from_immutable_snapshot", + "operation_id": "getPublishedSolution", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Имя при сдаче" + }, + "published_at": "2026-10-25T06:00:00Z", + "fields": [ + { + "id": 701, + "type": "number", + "purpose": "other", + "label": "Размер пилота", + "help_text": "", + "required": false, + "position": 0, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 702, + "type": "checkbox", + "purpose": "other", + "label": "Разрешение на демонстрацию", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 703, + "type": "file", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 701, + "value": 0 + }, + { + "field_id": 702, + "value": false + }, + { + "field_id": 703, + "value": { + "url": "https://cdn.example.org/solution/materials.zip" + } + } + ], + "team": { + "members": [ + { + "user": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "role": "Капитан", + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + } + }, + { + "name": "published_solution_list_legacy", + "operation_id": "getPublishedSolutions", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_list_competitive_pending_rule", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 0, + "next": null, + "previous": null, + "results": [] + } + }, + { + "name": "published_solution_list_missing_program", + "operation_id": "getPublishedSolutions", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_list_invalid_query", + "operation_id": "getPublishedSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_detail_unpublished", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_detail_other_program", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_detail_unsafe_id", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_detail_bad_authorization", + "operation_id": "getPublishedSolution", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_solution_list_bad_authorization", + "operation_id": "getPublishedSolutions", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_solutions_field_present_zero", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "submission_applicable": true, + "mode": "distributed", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "captain": { + "user_id": 5, + "full_name": "Капитан Тест", + "avatar": null + }, + "case_name": "Экология", + "submission_status": "submitted", + "evaluation_status": "awaiting_evaluation", + "created_at": "2026-10-23T00:00:00Z", + "submitted_at": "2026-10-24T00:00:00Z", + "assignments_total": 1, + "assignments_completed": 0, + "waiting_reason": "no_completed_evaluations" + } + ] + } + }, + { + "name": "manager_solutions_field_present_false", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "submission_applicable": true, + "mode": "distributed", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "captain": { + "user_id": 5, + "full_name": "Капитан Тест", + "avatar": null + }, + "case_name": "Экология", + "submission_status": "submitted", + "evaluation_status": "awaiting_evaluation", + "created_at": "2026-10-23T00:00:00Z", + "submitted_at": "2026-10-24T00:00:00Z", + "assignments_total": 1, + "assignments_completed": 0, + "waiting_reason": "no_completed_evaluations" + } + ] + } + }, + { + "name": "manager_solutions_field_missing", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "submission_applicable": false, + "mode": "open", + "results": [] + } + }, + { + "name": "manager_solutions_field_not_applicable_snapshot", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "submission_applicable": true, + "mode": "distributed", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "captain": { + "user_id": 5, + "full_name": "Капитан Тест", + "avatar": null + }, + "case_name": "Экология", + "submission_status": "submitted", + "evaluation_status": "awaiting_evaluation", + "created_at": "2026-10-23T00:00:00Z", + "submitted_at": "2026-10-24T00:00:00Z", + "assignments_total": 1, + "assignments_completed": 0, + "waiting_reason": "no_completed_evaluations" + } + ] + } + }, + { + "name": "manager_solutions_field_historical_snapshot", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "submission_applicable": true, + "mode": "distributed", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "captain": { + "user_id": 5, + "full_name": "Капитан Тест", + "avatar": null + }, + "case_name": "Экология", + "submission_status": "submitted", + "evaluation_status": "awaiting_evaluation", + "created_at": "2026-10-23T00:00:00Z", + "submitted_at": "2026-10-24T00:00:00Z", + "assignments_total": 1, + "assignments_completed": 0, + "waiting_reason": "no_completed_evaluations" + } + ] + } + }, + { + "name": "manager_solutions_field_noncompetitive_empty", + "operation_id": "getSolutionManagerSolutions", + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "submission_applicable": false, + "mode": "open", + "results": [] + } + }, + { + "name": "manager_solutions_field_required", + "operation_id": "getSolutionManagerSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_solutions_field_foreign_or_unknown", + "operation_id": "getSolutionManagerSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_solutions_field_unsafe", + "operation_id": "getSolutionManagerSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_solutions_answer_state_invalid", + "operation_id": "getSolutionManagerSolutions", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_export_field_required", + "operation_id": "exportSolutionProgramAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_export_field_foreign_or_unknown", + "operation_id": "exportSolutionProgramAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_export_filters_unsupported_kind", + "operation_id": "exportSolutionProgramAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "manager_export_answer_state_invalid", + "operation_id": "exportSolutionProgramAnalytics", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры фильтра.", + "errors": [], + "meta": {} + } + }, + { + "name": "context_creation_disabled", + "operation_id": "getSolutionContext", + "status": 200, + "response": { + "program_id": 12, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-30T18:00:00Z", + "can_create": false, + "blocked_reason": "solution_creation_disabled", + "current_application": null + } + }, + { + "name": "create_creation_disabled", + "operation_id": "createSolution", + "status": 409, + "request": {}, + "response": { + "code": "solution_creation_disabled", + "detail": "Создание новых решений временно приостановлено. Ранее созданные заявки доступны.", + "errors": [], + "meta": {} + } + }, + { + "name": "create_existing_creation_disabled", + "operation_id": "createSolution", + "status": 200, + "request": {}, + "response": { + "id": 301, + "program_id": 12, + "version": 8, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ] + } + } + }, + { + "name": "solution_action_preview_ready", + "operation_id": "previewSolutionSubmitAction", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Готовое решение" + }, + "errors": [] + } + ], + "ready_count": 1, + "already_submitted_count": 0, + "can_apply": true, + "preview_token": "opaque-preview-token" + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_incomplete", + "operation_id": "previewSolutionSubmitAction", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Готовое решение" + }, + "errors": [ + { + "path": "answers", + "code": "required", + "field_id": 1001, + "detail": "Заполните обязательное поле." + } + ] + } + ], + "ready_count": 0, + "already_submitted_count": 0, + "can_apply": false, + "preview_token": null + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_already", + "operation_id": "previewSolutionSubmitAction", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Готовое решение" + }, + "errors": [] + } + ], + "ready_count": 0, + "already_submitted_count": 1, + "can_apply": false, + "preview_token": null + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_mixed", + "operation_id": "previewSolutionSubmitAction", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Готовое решение" + }, + "errors": [] + }, + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 302, + "name": "Готовое решение" + }, + "errors": [] + } + ], + "ready_count": 1, + "already_submitted_count": 1, + "can_apply": true, + "preview_token": "opaque-preview-token" + }, + "request": { + "action": "submit", + "ids": [ + 301, + 302 + ] + } + }, + { + "name": "solution_action_preview_noncompetitive", + "operation_id": "previewSolutionSubmitAction", + "status": 409, + "response": { + "code": "not_competitive", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_forbidden", + "operation_id": "previewSolutionSubmitAction", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_anonymous", + "operation_id": "previewSolutionSubmitAction", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_foreign", + "operation_id": "previewSolutionSubmitAction", + "status": 404, + "response": { + "code": "not_found", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_preview_invalid", + "operation_id": "previewSolutionSubmitAction", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_action_preview_legacy", + "operation_id": "previewSolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ] + } + }, + { + "name": "solution_action_apply_ready", + "operation_id": "applySolutionSubmitAction", + "status": 200, + "response": { + "changed_count": 1, + "already_submitted_count": 0 + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_mixed", + "operation_id": "applySolutionSubmitAction", + "status": 200, + "response": { + "changed_count": 1, + "already_submitted_count": 1 + }, + "request": { + "action": "submit", + "ids": [ + 301, + 302 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_replay", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_bad_signature", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_expired", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_changed_state", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_other_manager", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_forbidden", + "operation_id": "applySolutionSubmitAction", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_foreign", + "operation_id": "applySolutionSubmitAction", + "status": 404, + "response": { + "code": "not_found", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_noncompetitive", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "not_competitive", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_legacy", + "operation_id": "applySolutionSubmitAction", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_invalid", + "operation_id": "applySolutionSubmitAction", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + } + }, + { + "name": "solution_action_apply_file_invalid", + "operation_id": "applySolutionSubmitAction", + "status": 400, + "response": { + "code": "invalid_answers", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "solution_action_apply_anonymous", + "operation_id": "applySolutionSubmitAction", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Проверьте действие.", + "errors": [], + "meta": {} + }, + "request": { + "action": "submit", + "ids": [ + 301 + ], + "preview_token": "opaque-preview-token" + } + }, + { + "name": "case_action_preview_ready", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Город", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [], + "can_apply": true, + "preview_token": "opaque-case-preview-token", + "is_submitted": false, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_empty_current", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": null, + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [], + "can_apply": true, + "preview_token": "opaque-case-preview-token", + "is_submitted": false, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_noncompetitive", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 13, + "solution_id": 302, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Город", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [], + "can_apply": true, + "preview_token": "opaque-case-preview-token", + "is_submitted": false, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_same", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Образование | наука", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [ + { + "path": "case_name", + "code": "case_unchanged", + "detail": "Выберите другой кейс.", + "field_id": 401 + } + ], + "can_apply": false, + "preview_token": null, + "is_submitted": false, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_submitted", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Город", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [], + "can_apply": true, + "preview_token": "opaque-submitted-case-preview-token", + "is_submitted": true, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_evaluation", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Город", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [ + { + "path": "case_name", + "code": "evaluation_started", + "detail": "У решения уже есть данные оценивания.", + "field_id": 401 + } + ], + "can_apply": false, + "preview_token": null, + "is_submitted": false, + "removed_assignments_count": 0, + "score_count": 0 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_bad_case", + "operation_id": "previewSolutionCaseChange", + "status": 400, + "response": { + "code": "invalid_answers", + "detail": "Выберите актуальный кейс.", + "errors": [ + { + "path": "case_name", + "code": "case_unavailable", + "detail": "Вариант недоступен.", + "field_id": 401 + } + ], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_anonymous", + "operation_id": "previewSolutionCaseChange", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Войдите в аккаунт.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_foreign_manager", + "operation_id": "previewSolutionCaseChange", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_foreign_solution", + "operation_id": "previewSolutionCaseChange", + "status": 404, + "response": { + "code": "not_found", + "detail": "Решение не найдено.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_legacy", + "operation_id": "previewSolutionCaseChange", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует проектную заявку.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_preview_unconfigured", + "operation_id": "previewSolutionCaseChange", + "status": 409, + "response": { + "code": "form_not_configured", + "detail": "Настройте форму решения.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_apply_changed", + "operation_id": "applySolutionCaseChange", + "status": 200, + "response": { + "solution_id": 301, + "solution_version": 8, + "updated_at": "2026-10-25T10:00:00Z" + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_stale", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_replay", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_expired", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_cross_actor", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_cross_solution", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_cross_case", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Повторите предпросмотр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_submitted", + "operation_id": "applySolutionCaseChange", + "status": 200, + "response": { + "solution_id": 301, + "solution_version": 8, + "updated_at": "2026-10-25T12:00:00Z" + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_bad_case", + "operation_id": "applySolutionCaseChange", + "status": 400, + "response": { + "code": "invalid_answers", + "detail": "Выберите актуальный кейс.", + "errors": [ + { + "path": "case_name", + "code": "case_unavailable", + "detail": "Вариант недоступен.", + "field_id": 401 + } + ], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_foreign_manager", + "operation_id": "applySolutionCaseChange", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "case_action_apply_invalid_payload", + "operation_id": "applySolutionCaseChange", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [], + "meta": {} + } + }, + { + "name": "case_action_apply_unknown_query", + "operation_id": "applySolutionCaseChange", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Неизвестный параметр.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "opaque-case-preview-token" + } + }, + { + "name": "invitation_role_one_character", + "operation_id": "createSolutionInvitation", + "status": 201, + "request": { + "expected_solution_version": 8, + "recipient_user_id": 9, + "role": "Р" + }, + "response": { + "invitation": { + "id": 701, + "version": 1, + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "captain": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "recipient": { + "id": 9, + "first_name": "Олег", + "last_name": "Иванов", + "avatar_url": null + }, + "role": "Р", + "specialization": "Исследования", + "motivational_letter": "Приглашаем в команду.", + "status": "pending", + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 9 + } + }, + { + "name": "invitation_role_maximum", + "operation_id": "createSolutionInvitation", + "status": 201, + "request": { + "expected_solution_version": 8, + "recipient_user_id": 9, + "role": "РРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРР" + }, + "response": { + "invitation": { + "id": 701, + "version": 1, + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "captain": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "recipient": { + "id": 9, + "first_name": "Олег", + "last_name": "Иванов", + "avatar_url": null + }, + "role": "РРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРРР", + "specialization": "Исследования", + "motivational_letter": "Приглашаем в команду.", + "status": "pending", + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 9 + } + }, + { + "name": "invitation_missing_role", + "operation_id": "createSolutionInvitation", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте поля приглашения.", + "errors": [ + { + "path": "role", + "code": "invalid", + "detail": "Укажите роль от 1 до 128 символов.", + "field_id": null + } + ], + "meta": {} + } + }, + { + "name": "invitation_blank_role", + "operation_id": "createSolutionInvitation", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте поля приглашения.", + "errors": [ + { + "path": "role", + "code": "invalid", + "detail": "Укажите роль от 1 до 128 символов.", + "field_id": null + } + ], + "meta": {} + } + }, + { + "name": "invitation_null_role", + "operation_id": "createSolutionInvitation", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте поля приглашения.", + "errors": [ + { + "path": "role", + "code": "invalid", + "detail": "Укажите роль от 1 до 128 символов.", + "field_id": null + } + ], + "meta": {} + } + }, + { + "name": "invitation_update_null_role", + "operation_id": "updateSolutionInvitation", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте поля приглашения.", + "errors": [ + { + "path": "role", + "code": "invalid", + "detail": "Укажите роль от 1 до 128 символов.", + "field_id": null + } + ], + "meta": {} + } + }, + { + "name": "invitation_legacy_null_role", + "operation_id": "getSolutionInvitation", + "status": 200, + "response": { + "id": 701, + "version": 1, + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "captain": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "recipient": { + "id": 9, + "first_name": "Олег", + "last_name": "Иванов", + "avatar_url": null + }, + "role": null, + "specialization": "Исследования", + "motivational_letter": "Приглашаем в команду.", + "status": "pending", + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + } + }, + { + "name": "invitation_legacy_null_role_accept", + "operation_id": "acceptSolutionInvitation", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 701, + "version": 2, + "program": { + "id": 12, + "name": "Кейс-чемпионат PROCOLLAB" + }, + "solution": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "captain": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "recipient": { + "id": 9, + "first_name": "Олег", + "last_name": "Иванов", + "avatar_url": null + }, + "role": null, + "specialization": "Исследования", + "motivational_letter": "Приглашаем в команду.", + "status": "accepted", + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "solution_version": 9 + } + }, + { + "name": "case_action_preview_submitted_assignment_effects", + "operation_id": "previewSolutionCaseChange", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "case_field_id": 401, + "current_case": "Город", + "case_name": "Образование | наука", + "solution_version": 7, + "errors": [], + "can_apply": true, + "preview_token": "opaque-submitted-case-preview-token", + "is_submitted": true, + "removed_assignments_count": 1, + "score_count": 2 + }, + "request": { + "case_name": "Образование | наука" + } + }, + { + "name": "case_action_apply_submitted_replay", + "operation_id": "applySolutionCaseChange", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "case_name": "Образование | наука", + "preview_token": "used-case-preview-token" + } + }, + { + "name": "admin_history_list", + "operation_id": "listSolutionAdminChanges", + "status": 200, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 71, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "case_field_id": 1001, + "previous_case": "Цифровой сервис", + "case_name": "Образование | наука", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + } + ] + }, + "query": { + "offset": 0, + "limit": 20 + } + }, + { + "name": "admin_history_detail", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 71, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "case_field_id": 1001, + "previous_case": "Цифровой сервис", + "case_name": "Образование | наука", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + }, + "before_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + }, + "after_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Образование | наука" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + } + } + }, + { + "name": "admin_history_draft_detail", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 72, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "case_field_id": 1001, + "previous_case": "Цифровой сервис", + "case_name": "Образование | наука", + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false + }, + "before_snapshot": null, + "after_snapshot": null + } + }, + { + "name": "admin_history_empty", + "operation_id": "listSolutionAdminChanges", + "status": 200, + "response": { + "count": 0, + "offset": 100, + "limit": 20, + "results": [] + }, + "query": { + "offset": 100, + "limit": 20 + } + }, + { + "name": "admin_history_anonymous", + "operation_id": "listSolutionAdminChanges", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Требуется авторизация.", + "errors": [], + "meta": {} + } + }, + { + "name": "admin_history_foreign_manager", + "operation_id": "listSolutionAdminChanges", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + } + }, + { + "name": "admin_history_foreign_solution", + "operation_id": "listSolutionAdminChanges", + "status": 404, + "response": { + "code": "not_found", + "detail": "Решение не найдено.", + "errors": [], + "meta": {} + } + }, + { + "name": "admin_history_foreign_change", + "operation_id": "readSolutionAdminChange", + "status": 404, + "response": { + "code": "not_found", + "detail": "Изменение не найдено.", + "errors": [], + "meta": {} + } + }, + { + "name": "admin_history_invalid_pagination", + "operation_id": "listSolutionAdminChanges", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте параметры запроса.", + "errors": [], + "meta": {} + }, + "query": { + "limit": 0 + } + }, + { + "name": "admin_history_unknown_detail_query", + "operation_id": "readSolutionAdminChange", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Неизвестный параметр запроса.", + "errors": [], + "meta": {} + }, + "query": { + "all": true + } + }, + { + "name": "admin_history_legacy", + "operation_id": "listSolutionAdminChanges", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + } + }, + { + "name": "member_removal_preview_submitted", + "operation_id": "previewSolutionMemberRemoval", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "member": { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + }, + "solution_version": 7, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [], + "can_apply": true, + "preview_token": "opaque-member-removal-preview-token" + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_preview_draft_pending", + "operation_id": "previewSolutionMemberRemoval", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "member": { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + }, + "solution_version": 7, + "is_submitted": false, + "remaining_member_count": 1, + "revoked_invitation_count": 1, + "score_count": 0, + "errors": [], + "can_apply": true, + "preview_token": "opaque-member-removal-preview-token" + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_preview_captain", + "operation_id": "previewSolutionMemberRemoval", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "member": { + "user": { + "id": 9, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": true + }, + "solution_version": 7, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [ + { + "path": "user_id", + "code": "captain_cannot_be_removed", + "detail": "Капитана нельзя исключить из состава. Для него снимите заявку с участия.", + "field_id": null + } + ], + "can_apply": false, + "preview_token": null + }, + "request": { + "user_id": 9 + } + }, + { + "name": "member_removal_apply_submitted", + "operation_id": "applySolutionMemberRemoval", + "status": 200, + "response": { + "solution_id": 301, + "solution_version": 8, + "updated_at": "2026-10-25T15:00:00Z" + }, + "request": { + "user_id": 10, + "preview_token": "opaque-member-removal-preview-token" + } + }, + { + "name": "member_removal_anonymous", + "operation_id": "previewSolutionMemberRemoval", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_captain_denied", + "operation_id": "previewSolutionMemberRemoval", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_foreign_manager", + "operation_id": "previewSolutionMemberRemoval", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_foreign_solution", + "operation_id": "previewSolutionMemberRemoval", + "status": 404, + "response": { + "code": "not_found", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_missing_member", + "operation_id": "previewSolutionMemberRemoval", + "status": 404, + "response": { + "code": "not_found", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_legacy", + "operation_id": "previewSolutionMemberRemoval", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + } + }, + { + "name": "member_removal_replay", + "operation_id": "applySolutionMemberRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10, + "preview_token": "opaque-member-removal-preview-token" + } + }, + { + "name": "member_removal_stale", + "operation_id": "applySolutionMemberRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10, + "preview_token": "opaque-member-removal-preview-token" + } + }, + { + "name": "member_removal_expired", + "operation_id": "applySolutionMemberRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10, + "preview_token": "opaque-member-removal-preview-token" + } + }, + { + "name": "member_removal_changed_target", + "operation_id": "applySolutionMemberRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10, + "preview_token": "opaque-member-removal-preview-token" + } + }, + { + "name": "member_removal_missing_token", + "operation_id": "applySolutionMemberRemoval", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "invalid_request": { + "user_id": 10 + } + }, + { + "name": "member_removal_invalid_user", + "operation_id": "previewSolutionMemberRemoval", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "invalid_request": { + "user_id": 0 + } + }, + { + "name": "member_removal_unknown_query", + "operation_id": "previewSolutionMemberRemoval", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "user_id": 10 + }, + "query": { + "all": true + } + }, + { + "name": "member_removal_history_detail", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 73, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + }, + "revoked_invitation_count": 0 + }, + "before_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + }, + { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + }, + "after_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + } + } + }, + { + "name": "member_removal_history_draft", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 73, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "removed_member": { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + }, + "revoked_invitation_count": 1 + }, + "before_snapshot": null, + "after_snapshot": null + } + }, + { + "name": "member_removal_history_mixed_list", + "operation_id": "listSolutionAdminChanges", + "status": 200, + "response": { + "count": 2, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 73, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + }, + "revoked_invitation_count": 0 + }, + { + "id": 71, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "case_field_id": 1001, + "previous_case": "Цифровой сервис", + "case_name": "Образование | наука", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + } + ] + } + }, + { + "name": "archive_preview_submitted", + "operation_id": "previewSolutionArchive", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": true, + "is_archived": false, + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "errors": [] + } + ], + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "opaque-archive-preview-token" + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_preview_draft", + "operation_id": "previewSolutionArchive", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": false, + "is_archived": false, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "errors": [] + } + ], + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "opaque-archive-preview-token" + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_preview_already_archived", + "operation_id": "previewSolutionArchive", + "status": 200, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": true, + "is_archived": true, + "score_count": 2, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [ + { + "path": "ids", + "code": "solution_archived", + "field_id": null, + "detail": "Решение уже снято с участия." + } + ] + } + ], + "score_count": 2, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": false, + "preview_token": null + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_apply", + "operation_id": "applySolutionArchive", + "status": 200, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 8, + "archived_at": "2026-10-25T15:00:00Z" + } + ] + }, + "request": { + "ids": [ + 301 + ], + "preview_token": "opaque-archive-preview-token" + } + }, + { + "name": "archive_anonymous", + "operation_id": "previewSolutionArchive", + "status": 401, + "response": { + "code": "authentication_required", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_captain_denied", + "operation_id": "previewSolutionArchive", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_foreign_manager", + "operation_id": "previewSolutionArchive", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_foreign_solution", + "operation_id": "previewSolutionArchive", + "status": 404, + "response": { + "code": "not_found", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_legacy", + "operation_id": "previewSolutionArchive", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_replay", + "operation_id": "applySolutionArchive", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ], + "preview_token": "opaque-archive-preview-token" + } + }, + { + "name": "archive_stale", + "operation_id": "applySolutionArchive", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ], + "preview_token": "opaque-archive-preview-token" + } + }, + { + "name": "archive_expired", + "operation_id": "applySolutionArchive", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "request": { + "ids": [ + 301 + ], + "preview_token": "opaque-archive-preview-token" + } + }, + { + "name": "archive_invalid_empty_selection", + "operation_id": "previewSolutionArchive", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "invalid_request": { + "ids": [] + } + }, + { + "name": "archive_invalid_duplicate_selection", + "operation_id": "previewSolutionArchive", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "invalid_request": { + "ids": [ + 301, + 301 + ] + } + }, + { + "name": "archive_missing_token", + "operation_id": "applySolutionArchive", + "status": 400, + "response": { + "code": "validation_error", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "invalid_request": { + "ids": [ + 301 + ] + } + }, + { + "name": "archive_read_submitted_owner", + "operation_id": "getSolution", + "status": 200, + "response": { + "id": 301, + "program_id": 12, + "version": 11, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": "2026-10-23T10:05:00Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "field_id": null, + "detail": "Решение снято с участия." + } + ] + }, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:00:00Z", + "reason": "detached" + } + } + }, + { + "name": "archive_read_draft_owner", + "operation_id": "getSolution", + "status": 200, + "response": { + "id": 301, + "program_id": 12, + "version": 11, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:05:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "field_id": null, + "detail": "Решение снято с участия." + } + ] + }, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:00:00Z", + "reason": "detached" + } + } + }, + { + "name": "archive_list_owned", + "operation_id": "listSolutions", + "status": 200, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 301, + "program_id": 12, + "name": "Сервис для студентов", + "captain": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "accepted_count": 2, + "case_value": "Цифровой сервис", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:00:00Z", + "reason": "detached" + } + } + ] + }, + "query": { + "participation": "archived" + } + }, + { + "name": "archive_default_active_empty", + "operation_id": "listSolutions", + "status": 200, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + } + }, + { + "name": "archive_owner_patch_frozen", + "operation_id": "updateSolution", + "status": 409, + "response": { + "code": "solution_archived", + "detail": "Проверьте данные и подтвердите действие заново.", + "errors": [], + "meta": {} + } + }, + { + "name": "archive_history_detail", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 71, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T15:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 2, + "score_count": 2, + "was_submitted": true, + "archived_at": "2026-10-25T15:00:00Z", + "reason": "detached", + "revoked_invitation_count": 1 + }, + "before_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + }, + "after_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "submitted_at": "2026-10-23T10:05:00Z" + }, + "archive_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "captured_at": "2026-10-25T15:00:00Z" + } + } + }, + { + "name": "archive_history_draft", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 71, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Сервис для студентов" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T15:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:00:00Z", + "reason": "detached", + "revoked_invitation_count": 1 + }, + "before_snapshot": null, + "after_snapshot": null, + "archive_snapshot": { + "name": "Сервис для студентов", + "captain_id": 7, + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + }, + { + "field_id": 1003, + "value": { + "url": "https://cdn.example.test/solutions/301/presentation.pdf" + } + } + ], + "captured_at": "2026-10-25T15:00:00Z" + } + } + }, + { + "name": "registration_removal_preview_member_submitted", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 200, + "response": { + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": true, + "effect": "remove_members", + "removed_members": [ + { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "opaque-registration-removal-proof" + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_preview_pending_only", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 200, + "response": { + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": true, + "effect": "revoke_invitations", + "removed_members": [], + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "opaque-registration-removal-proof" + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_preview_captain_submitted", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 200, + "response": { + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": true, + "effect": "archive", + "removed_members": [], + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 1, + "removed_member_count": 0, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "opaque-registration-removal-proof" + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_preview_member_draft", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 200, + "response": { + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 7, + "is_submitted": false, + "effect": "remove_members", + "removed_members": [ + { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 0 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "opaque-registration-removal-proof" + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_preview_without_solution", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 200, + "response": { + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "effects": [], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "opaque-registration-removal-proof" + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_apply_member", + "operation_id": "applySolutionRegistrationRemoval", + "status": 200, + "response": { + "audit_id": 801, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 8, + "change_id": 901, + "action": "remove_registrations" + } + ] + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_apply_captain", + "operation_id": "applySolutionRegistrationRemoval", + "status": 200, + "response": { + "audit_id": 801, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "solution_version": 8, + "change_id": 901, + "action": "archive" + } + ] + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_apply_without_solution", + "operation_id": "applySolutionRegistrationRemoval", + "status": 200, + "response": { + "audit_id": 801, + "deleted_count": 1, + "items": [] + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_audit_list", + "operation_id": "listSolutionRegistrationRemovals", + "status": 200, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 801, + "program_id": 12, + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "deleted_count": 1 + } + ] + } + }, + { + "name": "registration_removal_audit_detail", + "operation_id": "readSolutionRegistrationRemoval", + "status": 200, + "response": { + "audit": { + "id": 801, + "program_id": 12, + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "deleted_count": 1 + }, + "changes": [ + { + "id": 73, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "revoked_invitation_count": 1, + "removed_members": [ + { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + } + ], + "registration_removal_id": 801 + } + ] + } + }, + { + "name": "registration_removal_audit_no_solution", + "operation_id": "readSolutionRegistrationRemoval", + "status": 200, + "response": { + "audit": { + "id": 801, + "program_id": 12, + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "registrations": [ + { + "registration_id": 601, + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + } + } + ], + "deleted_count": 1 + }, + "changes": [] + } + }, + { + "name": "registration_removal_solution_history", + "operation_id": "readSolutionAdminChange", + "status": 200, + "response": { + "change": { + "id": 73, + "application": { + "kind": "solution", + "program_id": 12, + "solution_id": 301, + "name": "Решение команды" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Организатор", + "last_name": "Программы", + "avatar_url": null + }, + "actor_name": "Организатор Программы", + "created_at": "2026-10-25T12:00:00Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "revoked_invitation_count": 1, + "removed_members": [ + { + "user": { + "id": 10, + "first_name": "Участник", + "last_name": "Команды", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-24T10:00:00Z", + "is_captain": false + } + ], + "registration_removal_id": 801 + }, + "before_snapshot": null, + "after_snapshot": null + } + }, + { + "name": "registration_removal_stale", + "operation_id": "applySolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_replay", + "operation_id": "applySolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_expired", + "operation_id": "applySolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_other_actor", + "operation_id": "applySolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "solution_action_conflict", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ], + "preview_token": "opaque-registration-removal-proof" + } + }, + { + "name": "registration_removal_legacy_mode", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "solution_mode_unavailable", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_mixed_legacy", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 409, + "response": { + "code": "legacy_application_conflict", + "detail": "Срок сдачи завершен.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_foreign_registration", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 404, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_participant", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 403, + "response": { + "code": "permission_denied", + "detail": "Сдать решение может только капитан.", + "errors": [], + "meta": {} + }, + "request": { + "registration_ids": [ + 601 + ] + } + }, + { + "name": "registration_removal_foreign_audit", + "operation_id": "readSolutionRegistrationRemoval", + "status": 404, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "name": "registration_removal_invalid_empty", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 400, + "response": { + "code": "required_program_fields", + "detail": "Заполните обязательные поля.", + "errors": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ], + "meta": {} + }, + "invalid_request": { + "registration_ids": [] + } + }, + { + "name": "registration_removal_invalid_duplicate", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 400, + "response": { + "code": "required_program_fields", + "detail": "Заполните обязательные поля.", + "errors": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ], + "meta": {} + }, + "invalid_request": { + "registration_ids": [ + 601, + 601 + ] + } + }, + { + "name": "registration_removal_invalid_user_id_instead", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 400, + "response": { + "code": "required_program_fields", + "detail": "Заполните обязательные поля.", + "errors": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ], + "meta": {} + }, + "invalid_request": { + "user_id": 10 + } + }, + { + "name": "registration_removal_invalid_unknown", + "operation_id": "previewSolutionRegistrationRemoval", + "status": 400, + "response": { + "code": "required_program_fields", + "detail": "Заполните обязательные поля.", + "errors": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ], + "meta": {} + }, + "invalid_request": { + "registration_ids": [ + 601 + ], + "project_id": 1 + } + }, + { + "name": "rate_archived_solution_open", + "operation_id": "rateSolution", + "status": 409, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + } + }, + { + "name": "rate_archived_solution_distributed", + "operation_id": "rateSolution", + "status": 409, + "request": { + "expected_criteria_revision": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "scores": [ + { + "criterion_id": 21, + "value": "0" + }, + { + "criterion_id": 22, + "value": "4.5" + }, + { + "criterion_id": 23, + "value": "False" + }, + { + "criterion_id": 24, + "value": "" + } + ] + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_competitive_submitted_list", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Сданное конкурсное решение" + }, + "published_at": "2026-10-25T06:00:00Z", + "captain": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + } + }, + { + "name": "published_competitive_submitted_snapshot", + "operation_id": "getPublishedSolution", + "status": 200, + "response": { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Сданное конкурсное решение" + }, + "published_at": "2026-10-25T06:00:00Z", + "fields": [ + { + "id": 701, + "type": "number", + "purpose": "other", + "label": "Размер пилота", + "help_text": "", + "required": false, + "position": 0, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 702, + "type": "checkbox", + "purpose": "other", + "label": "Разрешение на демонстрацию", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 703, + "type": "file", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 701, + "value": 0 + }, + { + "field_id": 702, + "value": false + }, + { + "field_id": 703, + "value": { + "url": "https://cdn.example.org/solution/materials.zip" + } + } + ], + "team": { + "members": [ + { + "user": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "role": "Капитан", + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + } + }, + { + "name": "published_competitive_submitted_page", + "operation_id": "getPublishedSolutions", + "status": 200, + "response": { + "program_id": 14, + "count": 25, + "next": "https://local.test/programs/14/published-solutions/?limit=1&offset=1", + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 14, + "solution_id": 501, + "name": "Сданное конкурсное решение" + }, + "published_at": "2026-10-25T06:00:00Z", + "captain": { + "id": 11, + "first_name": "Иван", + "last_name": "Токарев", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + } + }, + { + "name": "published_competitive_draft_hidden", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_competitive_archived_hidden", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } + }, + { + "name": "published_competitive_unpublished_hidden", + "operation_id": "getPublishedSolution", + "status": 404, + "response": { + "code": "not_found", + "detail": "Контролируемая ошибка.", + "errors": [], + "meta": {} + } } ] } diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index f61c50d2..c425736c 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -3,8 +3,8 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "PROCOLLAB Program Solutions C00", - "version": "1.0.0", - "description": "Контракт для реализации и моков. Новые endpoints пока не реализованы. API base URL задается окружением. snake_case на проводе. Старые API не меняются." + "version": "1.21.0", + "description": "Контракт решений в программах для реализации и моков BE01–BE09. API base URL задается окружением. snake_case на проводе. Старые API не меняются." }, "servers": [ { @@ -21,7 +21,7 @@ "get": { "operationId": "getSolutionContext", "summary": "Режим программы и текущая заявка", - "description": "", + "description": "Existing current_application сохраняет приоритет. Для eligible пользователя без заявки выключенный program-scoped creation flag дает can_create=false и blocked_reason=solution_creation_disabled; режим/права/сроки сохраняются.", "parameters": [ { "name": "program_id", @@ -92,6 +92,19 @@ "blocked_reason": null, "current_application": null } + }, + "creation_disabled": { + "value": { + "program_id": 12, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-30T18:00:00Z", + "can_create": false, + "blocked_reason": "solution_creation_disabled", + "current_application": null + } } } } @@ -154,7 +167,7 @@ "get": { "operationId": "listSolutions", "summary": "Решения программы", - "description": "", + "description": "По умолчанию active. participation=archived/all дает собственные архивные работы капитану/сохраненному составу и organizer/admin; эксперт получает только активные назначенные работы. Архив исключен из current_application.", "parameters": [ { "name": "program_id", @@ -207,6 +220,19 @@ ], "default": "all" } + }, + { + "name": "participation", + "in": "query", + "required": false, + "schema": { + "enum": [ + "active", + "archived", + "all" + ], + "default": "active" + } } ], "responses": { @@ -301,7 +327,7 @@ "post": { "operationId": "createSolution", "summary": "Создать или вернуть заявку капитана", - "description": "JSON {} создает заявку с начальным названием. Участие в другой команде дает 409.", + "description": "JSON {} создает заявку с начальным названием. Участие в другой команде дает 409. При выключенном solution_creation_enabled новая заявка получает 409/solution_creation_disabled после проверки прав; captain repeat по-прежнему возвращает 200. Флаг не передается клиентом и не блокирует команды существующего решения.", "parameters": [ { "name": "program_id", @@ -513,6 +539,198 @@ ] } } + }, + "creation_disabled_existing": { + "value": { + "id": 301, + "program_id": 12, + "version": 8, + "name": "Сервис для студентов", + "captain_id": 7, + "created_at": "2026-10-23T09:00:00Z", + "updated_at": "2026-10-23T10:00:00Z", + "team": { + "members": [ + { + "user": { + "id": 7, + "first_name": "Иван", + "last_name": "Петров", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": true + }, + { + "user": { + "id": 8, + "first_name": "Анна", + "last_name": "Смирнова", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-23T09:00:00Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 12, + "revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 1001, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Цифровой сервис", + "Операционная эффективность" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1002, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1003, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 1004, + "type": "number", + "purpose": "other", + "label": "Ожидаемый эффект", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": 0, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 1005, + "type": "checkbox", + "purpose": "other", + "label": "Приложены расчеты", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 1001, + "value": "Цифровой сервис" + }, + { + "field_id": 1002, + "value": "Единый сервис поддержки студентов." + }, + { + "field_id": 1004, + "value": 0 + }, + { + "field_id": 1005, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-30T18:00:00Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "required", + "field_id": 1003, + "detail": "Загрузите презентацию." + } + ] + } + } } } } @@ -756,6 +974,16 @@ "application/json": { "schema": { "$ref": "#/components/schemas/Error" + }, + "examples": { + "creation_disabled": { + "value": { + "code": "solution_creation_disabled", + "detail": "Создание новых решений временно приостановлено. Ранее созданные заявки доступны.", + "errors": [], + "meta": {} + } + } } } } @@ -5795,39 +6023,7356 @@ } } } - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" + }, + "parameters": [ + { + "name": "preserve_original", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + }, + "description": "true сохраняет исходные байты и формат материалов решения без перекодирования изображений; без параметра общий upload работает как прежде." + } + ] } }, - "schemas": { - "User": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "/programs/{program_id}/solution-evaluation/": { + "get": { + "operationId": "getSolutionRatings", + "summary": "Работы для оценки экспертом", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } }, - "first_name": { - "type": "string" + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "case_scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "selected", + "without_case" + ], + "default": "all" + } + }, + { + "name": "case_name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "pending", + "completed" + ], + "default": "all" + } + }, + { + "name": "assigned_only", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingPage" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Сданные решения в текущей видимости эксперта. evaluation_counts содержит собственные pending/completed после search/case_scope/assigned_only, до status и limit/offset, как действующий список оценивания проектов. Счетчики не вычисляются из загруженной страницы." + } + }, + "/programs/{program_id}/solutions/{solution_id}/evaluation/": { + "get": { + "operationId": "getSolutionRating", + "summary": "Сданное решение и собственная оценка", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingFrame" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "operationId": "rateSolution", + "summary": "Сохранить собственную оценку решения", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RatingFrame" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateSolution" + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-evaluation/experts/{expert_id}/assignments/": { + "get": { + "operationId": "getSolutionExpertAssignments", + "summary": "Варианты назначений решений", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "expert_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignmentOptions" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "saveSolutionExpertAssignments", + "summary": "Сохранить назначения решений эксперту", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "expert_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignmentOptions" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAssignmentsWrite" + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-evaluation/allocation/": { + "post": { + "operationId": "previewSolutionAllocation", + "summary": "Предпросмотр распределения решений", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAllocationPlan" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAllocationWrite" + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-evaluation/allocation/apply/": { + "post": { + "operationId": "applySolutionAllocation", + "summary": "Применить распределение решений", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAssignmentReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAllocationApply" + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-evaluation/quota/": { + "get": { + "operationId": "previewSolutionQuota", + "summary": "Последствия изменения числа экспертов", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "required_evaluators", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionQuota" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "operationId": "saveSolutionQuota", + "summary": "Сохранить число экспертов без удаления оценок", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionQuota" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionQuotaWrite" + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-results/": { + "get": { + "operationId": "getSolutionResults", + "summary": "Результаты решений для организатора", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "case_scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "selected", + "without_case" + ] + } + }, + { + "name": "case_name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500 + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "complete", + "pending" + ] + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionResults" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/evaluation/applications/": { + "get": { + "operationId": "getCabinetApplications", + "summary": "Единая страница реальных назначений проектов и решений", + "parameters": [ + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "pending", + "completed" + ], + "default": "pending" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "program_id", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CabinetApplications" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Одна общая пагинация после фильтрации. pending/completed/unavailable считаются до q и program_id. Сортировка: deadline (null в конце), program_id, название без учета регистра, assignment_id, kind." + } + }, + "/programs/{program_id}/management/solution-results/export/": { + "get": { + "operationId": "exportSolutionResults", + "summary": "Выгрузка результатов решений в XLSX", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200 + } + }, + { + "name": "case_scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "selected", + "without_case" + ] + } + }, + { + "name": "case_name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500 + } + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "complete", + "pending" + ] + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "current", + "all" + ], + "default": "current" + } + } + ], + "responses": { + "200": { + "description": "Два листа с результатами одной программы", + "headers": { + "Content-Disposition": { + "schema": { + "type": "string" + }, + "example": "attachment; filename=\"program-12-solution-results.xlsx\"" + }, + "Cache-Control": { + "schema": { + "type": "string", + "const": "private, no-store" + } + }, + "Referrer-Policy": { + "schema": { + "type": "string", + "const": "no-referrer" + } + } + }, + "content": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Только организатор этой программы в режиме solution. Тот же расчет и порядок, что GET solution-results: места вычисляются до фильтрации, средние только по полным оценкам. scope=current (по умолчанию) применяет search, case_scope/case_name и status; scope=all выгружает все сданные решения, сохраняя order. Некорректный selected case_name остается ошибкой при обоих scope, как в действующей выгрузке проектов. Два листа: Рейтинг и Оценки экспертов. Столбец ID решения содержит ProgramSolution.id; Project и program_link_id не используются. Числа и формулы имеют прежнюю семантику; пользовательские строки остаются текстом, включая строки, начинающиеся с =. Черновики и чужие программы исключены. Оценки не публикуются участникам. Успешный ответ является бинарным XLSX; JSON возвращается только для ошибок C00." + } + }, + "/programs/{program_id}/analytics-widget/": { + "get": { + "operationId": "getProgramRoleWidget", + "summary": "Ролевой виджет текущего пользователя программы", + "description": "Прежний read-only endpoint. В legacy сохраняется ответ Project, в solution явный storage_mode и настоящая ссылка/счетчики решения. Роль organizer → expert → participant проверяется сервером. GET не меняет данные. Скрытые оценки, эксперты и регистрационные ответы не возвращаются. Ошибки сохраняют существующий DRF detail, не C00 Error.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleWidget" + } + } + }, + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + }, + "description": "private, no-store" + } + } + }, + "401": { + "description": "Существующая ошибка виджета", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetError" + } + } + } + }, + "403": { + "description": "Существующая ошибка виджета", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetError" + } + } + } + }, + "404": { + "description": "Существующая ошибка виджета", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetError" + } + } + } + }, + "409": { + "description": "Существующая ошибка виджета", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetError" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-cases/": { + "get": { + "operationId": "getSolutionCaseAnalytics", + "summary": "Read program-scoped solution case aggregates, manager only", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Current case options and unique registered accepted teams; noncompetitive submission counts are null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaseSolutionAnalytics" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-analytics/": { + "get": { + "operationId": "getSolutionManagerAnalytics", + "summary": "getSolutionManagerAnalytics", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagerAnalytics" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-analytics/assignments/": { + "get": { + "operationId": "getSolutionManagerAssignments", + "summary": "getSolutionManagerAssignments", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "completed", + "pending" + ], + "default": "all" + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagerAssignments" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-analytics/assignments/{assignment_id}/": { + "get": { + "operationId": "getSolutionManagerAssignment", + "summary": "getSolutionManagerAssignment", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "assignment_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagerAssignmentDetail" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solution-analytics/solutions/": { + "get": { + "operationId": "getSolutionManagerSolutions", + "summary": "getSolutionManagerSolutions", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200, + "default": "" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "not_submitted", + "awaiting_evaluation" + ], + "default": "all" + } + }, + { + "name": "submission", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "submitted", + "not_submitted" + ], + "default": "all" + } + }, + { + "name": "case_scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "selected", + "without_case" + ], + "default": "all" + } + }, + { + "name": "case_name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500 + } + }, + { + "name": "field_id", + "in": "query", + "required": false, + "description": "Stable ID из current формы или submitted snapshot своей программы; неизвестный/чужой ID: 400.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "answer_state", + "in": "query", + "required": false, + "description": "Applied snapshot/current форма; present/missing только для применимого поля; 0/false present. Не all требует field_id.", + "schema": { + "type": "string", + "enum": [ + "all", + "present", + "missing", + "not_applicable" + ], + "default": "all" + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManagerSolutions" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Manager-only private/no-store. Все фильтры пересекаются до count/pagination. Применимость и присутствие совпадают с field_coverage; нет нового readiness правила." + } + }, + "/programs/{program_id}/management/solution-analytics/export/": { + "get": { + "operationId": "exportSolutionProgramAnalytics", + "summary": "exportSolutionProgramAnalytics", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "program", + "solutions", + "submitted", + "scores" + ], + "default": "program" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200, + "default": "" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "not_submitted", + "awaiting_evaluation" + ], + "default": "all" + } + }, + { + "name": "submission", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "submitted", + "not_submitted" + ], + "default": "all" + } + }, + { + "name": "case_scope", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all", + "selected", + "without_case" + ], + "default": "all" + } + }, + { + "name": "case_name", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 500 + } + }, + { + "name": "field_id", + "in": "query", + "required": false, + "description": "Stable ID из current формы или submitted snapshot своей программы; неизвестный/чужой ID: 400.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "answer_state", + "in": "query", + "required": false, + "description": "Applied snapshot/current форма; present/missing только для применимого поля; 0/false present. Не all требует field_id.", + "schema": { + "type": "string", + "enum": [ + "all", + "present", + "missing", + "not_applicable" + ], + "default": "all" + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "kind=solutions/submitted: те же фильтры, что JSON list, все найденные без pagination; submitted пересекает status. kind=program/scores: полный scope, непустые фильтры 400. limit/offset не поддерживаются. Private/no-store; безопасный XLSX writer." + } + }, + "/programs/{program_id}/management/solution-analytics/attention/solutions-awaiting-evaluation/reminder-preview/": { + "post": { + "operationId": "previewSolutionExpertReminder", + "summary": "Проверить получателей напоминания экспертам о решениях", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionReminderPreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionReminderPreviewRequest" + } + } + } + }, + "description": "Организатор/администратор своей solution-программы; только competitive distributed. Selection IDs — настоящие solution IDs. Private, no-store." + } + }, + "/programs/{program_id}/management/solution-analytics/attention/solutions-awaiting-evaluation/reminders/": { + "post": { + "operationId": "confirmSolutionExpertReminder", + "summary": "Подтвердить напоминание экспертам о решениях", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Успех", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionReminderReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionReminderConfirm" + } + } + } + }, + "description": "Организатор/администратор своей solution-программы; только competitive distributed. Selection IDs — настоящие solution IDs. Private, no-store." + } + }, + "/programs/{program_id}/published-solutions/": { + "get": { + "operationId": "getPublishedSolutions", + "summary": "Прочитать публичные решения неконкурсной программы", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string", + "maxLength": 200, + "default": "" + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishedSolutionPage" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [], + "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. Доступ определяет сохраненный published_at, архивы исключаются; GET не публикует. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." + } + }, + "/programs/{program_id}/published-solutions/{solution_id}/": { + "get": { + "operationId": "getPublishedSolution", + "summary": "Прочитать публичные материалы и принятый состав решения", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Program-scoped manager read; private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishedSolution" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [], + "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. Доступ определяет сохраненный published_at, архивы исключаются; GET не публикует. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." + } + }, + "/programs/{program_id}/management/solutions/actions/preview/": { + "post": { + "operationId": "previewSolutionSubmitAction", + "summary": "Предварительная проверка первой сдачи организатором", + "description": "Только существующие organizer/admin права. Signed preview привязан к actor/program/IDs и сохраненному состоянию, срок 10 минут. Поздняя первая сдача допускается; обычный submit и уже сданный snapshot не меняются. Только action=submit. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionActionPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionActionPreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/actions/apply/": { + "post": { + "operationId": "applySolutionSubmitAction", + "summary": "Подтверждение первой сдачи организатором", + "description": "Только существующие organizer/admin права. Signed preview привязан к actor/program/IDs и сохраненному состоянию, срок 10 минут. Поздняя первая сдача допускается; обычный submit и уже сданный snapshot не меняются. Только action=submit. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionActionApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionActionResult" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/case/preview/": { + "post": { + "operationId": "previewSolutionCaseChange", + "summary": "Предпросмотр смены кейса черновика", + "description": "Organizer/admin своей программы. Signed proof10min и program→solution locks; state recheck включает оценки/назначения/scopes. После сдачи меняется только кейс, исходный snapshot сохраняется в append-only истории до/после; остальные материалы/состав/дата сдачи и оценки сохраняются. Снимаются только несовместимые неоцененные назначения. Обычные participant writers остаются frozen. Legacy project handlers не меняются. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionCaseChangePreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionCaseChangePreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/case/apply/": { + "post": { + "operationId": "applySolutionCaseChange", + "summary": "Подтверждение смены кейса черновика", + "description": "Organizer/admin своей программы. Signed proof10min и program→solution locks; state recheck включает оценки/назначения/scopes. После сдачи меняется только кейс, исходный snapshot сохраняется в append-only истории до/после; остальные материалы/состав/дата сдачи и оценки сохраняются. Снимаются только несовместимые неоцененные назначения. Обычные participant writers остаются frozen. Legacy project handlers не меняются. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionCaseChangeApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/history/": { + "get": { + "operationId": "listSolutionAdminChanges", + "summary": "История административных изменений решения", + "description": "Только organizer/admin своей программы и solution mode. История ограничена настоящей парой program/solution/change; before/after показывают сохраненную сдачу без регистрации или иных private DTO. Списки newest first, limit/offset. Неизвестные query запрещены. Чтение доступно независимо от текущей формы/creation flag/deadline. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAdminChangePage" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/history/{change_id}/": { + "get": { + "operationId": "readSolutionAdminChange", + "summary": "История административных изменений решения", + "description": "Только organizer/admin своей программы и solution mode. История ограничена настоящей парой program/solution/change; before/after показывают сохраненную сдачу без регистрации или иных private DTO. Списки newest first, limit/offset. Неизвестные query запрещены. Чтение доступно независимо от текущей формы/creation flag/deadline. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "change_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionAdminChangeDetail" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/members/remove/preview/": { + "post": { + "operationId": "previewSolutionMemberRemoval", + "summary": "Исключение участника организатором", + "description": "Только organizer/admin своей программы, solution mode. Signed proof10min связан с actor/program/solution/user и текущими данными. Общие program→solution locks и recheck. До и после сдачи исключается принятый участник, кроме капитана; min_size остается проверкой сдачи. Регистрация, ответы, файлы, оценки и назначения сохраняются. Pending приглашения исключенного отзываются; accepted/declined история не дает доступа к материалам и сохраняется. Submitted snapshot меняет только members; исходный snapshot и исключенный Member сохраняются в append-only истории. Обычные writers остаются frozen. Независимо от формы/срока/creation flag; legacy handlers не меняются. Неизвестные поля/query запрещены. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionMemberRemovalPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionMemberRemovalPreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/{solution_id}/members/remove/apply/": { + "post": { + "operationId": "applySolutionMemberRemoval", + "summary": "Исключение участника организатором", + "description": "Только organizer/admin своей программы, solution mode. Signed proof10min связан с actor/program/solution/user и текущими данными. Общие program→solution locks и recheck. До и после сдачи исключается принятый участник, кроме капитана; min_size остается проверкой сдачи. Регистрация, ответы, файлы, оценки и назначения сохраняются. Pending приглашения исключенного отзываются; accepted/declined история не дает доступа к материалам и сохраняется. Submitted snapshot меняет только members; исходный snapshot и исключенный Member сохраняются в append-only истории. Обычные writers остаются frozen. Независимо от формы/срока/creation flag; legacy handlers не меняются. Неизвестные поля/query запрещены. private, no-store.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "solution_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionMemberRemovalApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MutationReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/archive/preview/": { + "post": { + "operationId": "previewSolutionArchive", + "summary": "Снятие решений с участия с сохранением работ", + "description": "Только organizer/admin своей программы, solution mode. Bulk signed proof10min связан с actor/program/ids и актуальными работами/составом/приглашениями/оценками/назначениями. Program→solutions locks/recheck; stale/replay409. Снятие до/после сдачи сохраняет solution/answers/UserFile/roster/scores и исходную сдачу. Pending отзываются, все назначения снимаются, включая оцененные; оценки сохраняются. Архив не участвует в текущей заявке/эксклюзивности/оценивании/аналитике/результатах/публикации. Captain/members/manager читают архив отдельно; новые заявки подчиняются текущим правилам регистрации/срока/creation flag. Draft получает archive snapshot без искусственного submit; submitted snapshot не меняется. Неизвестные body/query запрещены. private, no-store. Старые Project handlers/данные не меняются.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionArchivePreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionArchivePreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/solutions/archive/apply/": { + "post": { + "operationId": "applySolutionArchive", + "summary": "Снятие решений с участия с сохранением работ", + "description": "Только organizer/admin своей программы, solution mode. Bulk signed proof10min связан с actor/program/ids и актуальными работами/составом/приглашениями/оценками/назначениями. Program→solutions locks/recheck; stale/replay409. Снятие до/после сдачи сохраняет solution/answers/UserFile/roster/scores и исходную сдачу. Pending отзываются, все назначения снимаются, включая оцененные; оценки сохраняются. Архив не участвует в текущей заявке/эксклюзивности/оценивании/аналитике/результатах/публикации. Captain/members/manager читают архив отдельно; новые заявки подчиняются текущим правилам регистрации/срока/creation flag. Draft получает archive snapshot без искусственного submit; submitted snapshot не меняется. Неизвестные body/query запрещены. private, no-store. Старые Project handlers/данные не меняются.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionArchiveApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionArchiveReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/registrations/remove/preview/": { + "post": { + "operationId": "previewSolutionRegistrationRemoval", + "summary": "Удаление регистраций с сохранением решений", + "description": "Организатор/admin своей программы в solution mode. registration_ids — ID PartnerProgramUserProfile, не User/solution. Signed preview 10 минут связан с actor/program/selection и текущими регистрациями/материалами/командой/приглашениями/оценками/назначениями. Program → profiles → ordered solutions; атомарные recheck, эффекты, удаление регистраций и неизменяемый аудит. Капитан: архив reason=registration_removed, состав/материалы/оценки сохранены, pending и назначения сняты. Принятый участник: удалить из текущего состава и снимка, история до/после сохранена, оценки и назначения остаются. Только приглашенный: отозвать pending без изменения состава. Архивные заявки не меняются. Без решения: регистрация удаляется с отдельным аудитом. Auth User/manager/expert, другие программы, Project/UserFile не удаляются. Смешанная legacy-связь блокирует всю команду: legacy_application_conflict. Старые handlers не меняются. Deadline/creation flag не блокируют административное исключение. Stale/replay409, чужая регистрация404; private, no-store. Unknown body/query запрещены. История удаления доступна только организатору/admin своей программы.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalPreviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalPreview" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/registrations/remove/apply/": { + "post": { + "operationId": "applySolutionRegistrationRemoval", + "summary": "Удаление регистраций с сохранением решений", + "description": "Организатор/admin своей программы в solution mode. registration_ids — ID PartnerProgramUserProfile, не User/solution. Signed preview 10 минут связан с actor/program/selection и текущими регистрациями/материалами/командой/приглашениями/оценками/назначениями. Program → profiles → ordered solutions; атомарные recheck, эффекты, удаление регистраций и неизменяемый аудит. Капитан: архив reason=registration_removed, состав/материалы/оценки сохранены, pending и назначения сняты. Принятый участник: удалить из текущего состава и снимка, история до/после сохранена, оценки и назначения остаются. Только приглашенный: отозвать pending без изменения состава. Архивные заявки не меняются. Без решения: регистрация удаляется с отдельным аудитом. Auth User/manager/expert, другие программы, Project/UserFile не удаляются. Смешанная legacy-связь блокирует всю команду: legacy_application_conflict. Старые handlers не меняются. Deadline/creation flag не блокируют административное исключение. Stale/replay409, чужая регистрация404; private, no-store. Unknown body/query запрещены. История удаления доступна только организатору/admin своей программы.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalReceipt" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/registrations/removals/": { + "get": { + "operationId": "listSolutionRegistrationRemovals", + "summary": "Аудит удаления регистраций", + "description": "Организатор/admin своей программы в solution mode. registration_ids — ID PartnerProgramUserProfile, не User/solution. Signed preview 10 минут связан с actor/program/selection и текущими регистрациями/материалами/командой/приглашениями/оценками/назначениями. Program → profiles → ordered solutions; атомарные recheck, эффекты, удаление регистраций и неизменяемый аудит. Капитан: архив reason=registration_removed, состав/материалы/оценки сохранены, pending и назначения сняты. Принятый участник: удалить из текущего состава и снимка, история до/после сохранена, оценки и назначения остаются. Только приглашенный: отозвать pending без изменения состава. Архивные заявки не меняются. Без решения: регистрация удаляется с отдельным аудитом. Auth User/manager/expert, другие программы, Project/UserFile не удаляются. Смешанная legacy-связь блокирует всю команду: legacy_application_conflict. Старые handlers не меняются. Deadline/creation flag не блокируют административное исключение. Stale/replay409, чужая регистрация404; private, no-store. Unknown body/query запрещены. История удаления доступна только организатору/admin своей программы.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalAuditPage" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/programs/{program_id}/management/registrations/removals/{audit_id}/": { + "get": { + "operationId": "readSolutionRegistrationRemoval", + "summary": "Аудит удаления регистраций", + "description": "Организатор/admin своей программы в solution mode. registration_ids — ID PartnerProgramUserProfile, не User/solution. Signed preview 10 минут связан с actor/program/selection и текущими регистрациями/материалами/командой/приглашениями/оценками/назначениями. Program → profiles → ordered solutions; атомарные recheck, эффекты, удаление регистраций и неизменяемый аудит. Капитан: архив reason=registration_removed, состав/материалы/оценки сохранены, pending и назначения сняты. Принятый участник: удалить из текущего состава и снимка, история до/после сохранена, оценки и назначения остаются. Только приглашенный: отозвать pending без изменения состава. Архивные заявки не меняются. Без решения: регистрация удаляется с отдельным аудитом. Auth User/manager/expert, другие программы, Project/UserFile не удаляются. Смешанная legacy-связь блокирует всю команду: legacy_application_conflict. Старые handlers не меняются. Deadline/creation flag не блокируют административное исключение. Stale/replay409, чужая регистрация404; private, no-store. Unknown body/query запрещены. История удаления доступна только организатору/admin своей программы.", + "parameters": [ + { + "name": "program_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "audit_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "Результат", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalAuditDetail" + } + } + } + }, + "400": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Контролируемая ошибка", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "avatar_url": { + "anyOf": [ + { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "first_name", + "last_name", + "avatar_url" + ], + "additionalProperties": false + }, + "ProgramRef": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "SolutionRef": { + "type": "object", + "properties": { + "kind": { + "const": "solution" + }, + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "program_id", + "solution_id", + "name" + ], + "additionalProperties": false + }, + "LegacyRef": { + "type": "object", + "properties": { + "kind": { + "const": "legacy_project" + }, + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "project_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "program_link_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "program_id", + "project_id", + "program_link_id", + "name" + ], + "additionalProperties": false + }, + "ApplicationRef": { + "oneOf": [ + { + "$ref": "#/components/schemas/SolutionRef" + }, + { + "$ref": "#/components/schemas/LegacyRef" + } + ] + }, + "Issue": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "code": { + "type": "string" + }, + "field_id": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "detail": { + "type": "string" + }, + "criterion_id": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 9007199254740991, + "description": "ID критерия оценки; не подменяет field_id поля решения. Необязательное расширение 1.3.0." + } + }, + "required": [ + "path", + "code", + "field_id", + "detail" + ], + "additionalProperties": false + }, + "Error": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + }, + "meta": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "code", + "detail", + "errors", + "meta" + ], + "additionalProperties": false + }, + "Context": { + "type": "object", + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "storage_mode": { + "enum": [ + "legacy_project", + "solution" + ] + }, + "is_registered": { + "type": "boolean" + }, + "is_competitive": { + "type": "boolean" + }, + "submission_open": { + "type": "boolean" + }, + "submission_deadline": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "can_create": { + "type": "boolean" + }, + "blocked_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "current_application": { + "anyOf": [ + { + "$ref": "#/components/schemas/ApplicationRef" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "program_id", + "storage_mode", + "is_registered", + "is_competitive", + "submission_open", + "submission_deadline", + "can_create", + "blocked_reason", + "current_application" + ], + "additionalProperties": false + }, + "Validation": { + "type": "object", + "properties": { + "min_length": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "max_length": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "allowed_extensions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+$" + } + }, + "max_file_size_bytes": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 104857600 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "min_length", + "max_length", + "minimum", + "maximum", + "allowed_extensions", + "max_file_size_bytes" + ], + "additionalProperties": false + }, + "FieldType": { + "enum": [ + "text", + "textarea", + "url", + "number", + "select", + "checkbox", + "file", + "radio" + ] + }, + "Purpose": { + "enum": [ + "other", + "case", + "presentation", + "repository", + "demo", + "video", + "run_instructions" + ] + }, + "TemplateName": { + "enum": [ + "case_championship", + "hackathon" + ] + }, + "Field": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "$ref": "#/components/schemas/FieldType" + }, + "purpose": { + "$ref": "#/components/schemas/Purpose" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help_text": { + "type": "string", + "maxLength": 4000 + }, + "required": { + "type": "boolean" + }, + "position": { + "type": "integer", + "minimum": 0 + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "validation": { + "$ref": "#/components/schemas/Validation" + } + }, + "required": [ + "id", + "type", + "purpose", + "label", + "help_text", + "required", + "position", + "options", + "validation" + ], + "additionalProperties": false + }, + "FieldWrite": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "$ref": "#/components/schemas/FieldType" + }, + "purpose": { + "$ref": "#/components/schemas/Purpose" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "help_text": { + "type": "string", + "maxLength": 4000 + }, + "required": { + "type": "boolean" + }, + "position": { + "type": "integer", + "minimum": 0 + }, + "options": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "validation": { + "$ref": "#/components/schemas/Validation" + } + }, + "required": [ + "id", + "type", + "purpose", + "label", + "help_text", + "required", + "position", + "options", + "validation" + ], + "additionalProperties": false + }, + "Form": { + "type": "object", + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "template": { + "anyOf": [ + { + "$ref": "#/components/schemas/TemplateName" + }, + { + "type": "null" + } + ] + }, + "is_configured": { + "type": "boolean" + }, + "is_snapshot": { + "type": "boolean" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Field" + } + } + }, + "required": [ + "program_id", + "revision", + "template", + "is_configured", + "is_snapshot", + "fields" + ], + "additionalProperties": false + }, + "FormWrite": { + "type": "object", + "properties": { + "expected_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "template": { + "$ref": "#/components/schemas/TemplateName" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldWrite" + } + } + }, + "required": [ + "expected_revision", + "template", + "fields" + ], + "additionalProperties": false + }, + "FormManagement": { + "type": "object", + "properties": { + "form": { + "$ref": "#/components/schemas/Form" + }, + "has_solutions": { + "type": "boolean" + }, + "has_answers": { + "type": "boolean" + }, + "affected_draft_count": { + "type": "integer", + "minimum": 0 + }, + "fields_usage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "answers_count": { + "type": "integer", + "minimum": 0 + }, + "can_delete": { + "type": "boolean" + }, + "can_change_type": { + "type": "boolean" + } + }, + "required": [ + "field_id", + "answers_count", + "can_delete", + "can_change_type" + ], + "additionalProperties": false + } + } + }, + "required": [ + "form", + "has_solutions", + "has_answers", + "affected_draft_count", + "fields_usage" + ], + "additionalProperties": false + }, + "Template": { + "type": "object", + "properties": { + "key": { + "$ref": "#/components/schemas/TemplateName" + }, + "label": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FieldWrite" + } + } + }, + "required": [ + "key", + "label", + "fields" + ], + "additionalProperties": false + }, + "Templates": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + }, + "max_file_size_bytes": { + "const": 104857600 + }, + "files_per_field": { + "const": 1 + } + }, + "required": [ + "results", + "max_file_size_bytes", + "files_per_field" + ], + "additionalProperties": false + }, + "FileRef": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "AnswerValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "$ref": "#/components/schemas/FileRef" + } + ] + }, + "Answer": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "value": { + "$ref": "#/components/schemas/AnswerValue" + } + }, + "required": [ + "field_id", + "value" + ], + "additionalProperties": false + }, + "AnswerPatch": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "value": { + "anyOf": [ + { + "$ref": "#/components/schemas/AnswerValue" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "field_id", + "value" + ], + "additionalProperties": false + }, + "Member": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/User" + }, + "role": { + "anyOf": [ + { + "type": "string", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "specialization": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "joined_at": { + "type": "string", + "format": "date-time" + }, + "is_captain": { + "type": "boolean" + } + }, + "required": [ + "user", + "role", + "specialization", + "joined_at", + "is_captain" + ], + "additionalProperties": false + }, + "Team": { + "type": "object", + "properties": { + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" + } + }, + "accepted_count": { + "type": "integer", + "minimum": 0 + }, + "pending_count": { + "type": "integer", + "minimum": 0 + }, + "reserved_count": { + "type": "integer", + "minimum": 0 + }, + "min_size": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "max_size": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "is_frozen": { + "type": "boolean" + } + }, + "required": [ + "members", + "accepted_count", + "pending_count", + "reserved_count", + "min_size", + "max_size", + "is_frozen" + ], + "additionalProperties": false + }, + "Permissions": { + "type": "object", + "properties": { + "can_edit": { + "type": "boolean" + }, + "can_manage_team": { + "type": "boolean" + }, + "can_invite": { + "type": "boolean" + }, + "can_submit": { + "type": "boolean" + }, + "can_leave": { + "type": "boolean", + "description": "Собственный выход принятого участника: false для капитана, сданного решения и отключенного режима. Необязательное совместимое поле; при отсутствии клиент считает false." + } + }, + "required": [ + "can_edit", + "can_manage_team", + "can_invite", + "can_submit" + ], + "additionalProperties": false + }, + "Submission": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "status": { + "enum": [ + "draft", + "submitted" + ] + }, + "submission_open": { + "type": "boolean" + }, + "deadline": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "submitted_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "required", + "status", + "submission_open", + "deadline", + "submitted_at" + ], + "additionalProperties": false + }, + "Readiness": { + "type": "object", + "properties": { + "is_ready": { + "type": "boolean" + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + } + }, + "required": [ + "is_ready", + "issues" + ], + "additionalProperties": false + }, + "Solution": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "captain_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "team": { + "$ref": "#/components/schemas/Team" + }, + "form": { + "$ref": "#/components/schemas/Form" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Answer" + } + }, + "submission": { + "$ref": "#/components/schemas/Submission" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "readiness": { + "anyOf": [ + { + "$ref": "#/components/schemas/Readiness" + }, + { + "type": "null" + } + ] + }, + "participation": { + "$ref": "#/components/schemas/SolutionParticipation" + } + }, + "required": [ + "id", + "program_id", + "version", + "name", + "captain_id", + "created_at", + "updated_at", + "team", + "form", + "answers", + "submission", + "permissions", + "readiness" + ], + "additionalProperties": false + }, + "SolutionSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "captain": { + "$ref": "#/components/schemas/User" + }, + "accepted_count": { + "type": "integer", + "minimum": 0 + }, + "case_value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "enum": [ + "draft", + "submitted" + ] + }, + "submitted_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "participation": { + "$ref": "#/components/schemas/SolutionParticipation" + } + }, + "required": [ + "id", + "program_id", + "name", + "captain", + "accepted_count", + "case_value", + "status", + "submitted_at" + ], + "additionalProperties": false + }, + "CreateSolution": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [], + "additionalProperties": false + }, + "UpdateSolution": { + "type": "object", + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1 + }, + "expected_form_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AnswerPatch" + } + } + }, + "required": [ + "expected_version", + "expected_form_revision" + ], + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "answers" + ] + } + ] + }, + "Submit": { + "type": "object", + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1 + }, + "expected_form_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "expected_version", + "expected_form_revision" + ], + "additionalProperties": false + }, + "CaptainCommand": { + "type": "object", + "properties": { + "expected_solution_version": { + "type": "integer", + "minimum": 1 + }, + "new_captain_user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "expected_solution_version", + "new_captain_user_id" + ], + "additionalProperties": false + }, + "TeamCommand": { + "type": "object", + "properties": { + "expected_solution_version": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "expected_solution_version" + ], + "additionalProperties": false + }, + "InvitationPermissions": { + "type": "object", + "properties": { + "can_edit": { + "type": "boolean" + }, + "can_revoke": { + "type": "boolean" + }, + "can_accept": { + "type": "boolean" + }, + "can_decline": { + "type": "boolean" + } + }, + "required": [ + "can_edit", + "can_revoke", + "can_accept", + "can_decline" + ], + "additionalProperties": false + }, + "Invitation": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "program": { + "$ref": "#/components/schemas/ProgramRef" + }, + "solution": { + "$ref": "#/components/schemas/SolutionRef" + }, + "captain": { + "$ref": "#/components/schemas/User" + }, + "recipient": { + "$ref": "#/components/schemas/User" + }, + "role": { + "anyOf": [ + { + "type": "string", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "specialization": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "motivational_letter": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "status": { + "enum": [ + "pending", + "accepted", + "declined" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "permissions": { + "$ref": "#/components/schemas/InvitationPermissions" + } + }, + "required": [ + "id", + "version", + "program", + "solution", + "captain", + "recipient", + "role", + "specialization", + "motivational_letter", + "status", + "created_at", + "updated_at", + "permissions" + ], + "additionalProperties": false + }, + "CreateInvitation": { + "type": "object", + "properties": { + "expected_solution_version": { + "type": "integer", + "minimum": 1 + }, + "recipient_user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "specialization": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "motivational_letter": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "expected_solution_version", + "recipient_user_id", + "role" + ], + "additionalProperties": false + }, + "UpdateInvitation": { + "type": "object", + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1 + }, + "expected_solution_version": { + "type": "integer", + "minimum": 1 + }, + "role": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "\\S" + }, + "specialization": { + "anyOf": [ + { + "type": "string", + "maxLength": 100 + }, + { + "type": "null" + } + ] + }, + "motivational_letter": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "expected_version", + "expected_solution_version" + ], + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "role" + ] + }, + { + "required": [ + "specialization" + ] + }, + { + "required": [ + "motivational_letter" + ] + } + ] + }, + "InvitationAction": { + "type": "object", + "properties": { + "expected_version": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "expected_version" + ], + "additionalProperties": false + }, + "InvitationCreated": { + "type": "object", + "properties": { + "invitation": { + "$ref": "#/components/schemas/Invitation" + }, + "solution_version": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "invitation", + "solution_version" + ], + "additionalProperties": false + }, + "InvitationActionResult": { + "type": "object", + "properties": { + "invitation": { + "$ref": "#/components/schemas/Invitation" + }, + "application": { + "anyOf": [ + { + "$ref": "#/components/schemas/SolutionRef" + }, + { + "type": "null" + } + ] + }, + "solution_version": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "invitation", + "application", + "solution_version" + ], + "additionalProperties": false + }, + "MutationReceipt": { + "type": "object", + "properties": { + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "solution_version": { + "type": "integer", + "minimum": 1 + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "solution_id", + "solution_version", + "updated_at" + ], + "additionalProperties": false + }, + "SolutionPage": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionSummary" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ], + "additionalProperties": false + }, + "InvitationPage": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "previous": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ], + "additionalProperties": false + }, + "UploadResponse": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "SubmissionSnapshot": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "captain_id", + "members", + "form", + "answers", + "submitted_at" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 256 + }, + "captain_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" + } + }, + "form": { + "$ref": "#/components/schemas/Form" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Answer" + } + }, + "submitted_at": { + "type": "string", + "format": "date-time" + } + } + }, + "RatingCriterion": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "float", + "bool", + "str" + ] + }, + "min_value": { + "type": [ + "number", + "null" + ] + }, + "max_value": { + "type": [ + "number", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ], + "maxLength": 1000, + "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + } + }, + "required": [ + "id", + "name", + "description", + "type", + "min_value", + "max_value", + "value" + ], + "additionalProperties": false + }, + "RatingValue": { + "type": "object", + "properties": { + "criterion_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "value": { + "type": [ + "string", + "null" + ], + "maxLength": 1000, + "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + } + }, + "required": [ + "criterion_id", + "value" + ], + "additionalProperties": false + }, + "RateSolution": { + "type": "object", + "properties": { + "expected_criteria_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingValue" + }, + "minItems": 1 + } + }, + "required": [ + "expected_criteria_revision", + "scores" + ], + "additionalProperties": false + }, + "RatingSummary": { + "type": "object", + "properties": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "assigned": { + "type": "boolean" + }, + "is_completed": { + "type": "boolean" + }, + "can_rate": { + "type": "boolean" + }, + "blocked_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "application", + "case_name", + "assigned", + "is_completed", + "can_rate", + "blocked_reason" + ], + "additionalProperties": false + }, + "RatingPage": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "next": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "previous": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingSummary" + } + }, + "evaluation_counts": { + "type": "object", + "additionalProperties": false, + "properties": { + "pending": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "pending", + "completed" + ], + "description": "Собственные оценки текущего эксперта после search/case/assigned_only, до status и пагинации. Значение возвращает актуальный backend; optional для совместимости старых моков и клиентов." + }, + "case_options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Текущие точные варианты кейса для фильтра эксперта этой программы. Без полей/ответов закрытой формы; optional для совместимости старых клиентов." + } + }, + "required": [ + "count", + "next", + "previous", + "results" + ], + "additionalProperties": false + }, + "RatingFrame": { + "type": "object", + "properties": { + "program": { + "$ref": "#/components/schemas/ProgramRef" + }, + "solution": { + "$ref": "#/components/schemas/Solution" + }, + "criteria_revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "criteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingCriterion" + } + }, + "managed_evaluation": { + "type": "boolean" + }, + "can_rate": { + "type": "boolean" + }, + "blocked_reason": { + "type": [ + "string", + "null" + ] + }, + "is_completed": { + "type": "boolean" + }, + "rated_count": { + "type": "integer", + "minimum": 0 + }, + "max_rates": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "required_evaluators": { + "type": "integer", + "minimum": 1 + }, + "deadline": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "required": [ + "program", + "solution", + "criteria_revision", + "criteria", + "managed_evaluation", + "can_rate", + "blocked_reason", + "is_completed", + "rated_count", + "max_rates", + "required_evaluators", + "deadline" + ], + "additionalProperties": false + }, + "AssignmentOption": { + "type": "object", + "properties": { + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "submitted": { + "type": "boolean" + }, + "assigned": { + "type": "boolean" + }, + "protected": { + "type": "boolean" + }, + "is_completed": { + "type": "boolean" + }, + "own_team": { + "type": "boolean" + }, + "eligible": { + "type": "boolean" + } + }, + "required": [ + "solution_id", + "name", + "case_name", + "submitted", + "assigned", + "protected", + "is_completed", + "own_team", + "eligible" + ], + "additionalProperties": false + }, + "AssignmentOptions": { + "type": "object", + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expert_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "solutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignmentOption" + } + } + }, + "required": [ + "program_id", + "expert_id", + "revision", + "solutions" + ], + "additionalProperties": false + }, + "SolutionAssignmentsWrite": { + "type": "object", + "properties": { + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "solution_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "uniqueItems": true + } + }, + "required": [ + "revision", + "solution_ids" + ], + "additionalProperties": false + }, + "SolutionCasePool": { + "type": "object", + "properties": { + "case_name": { + "type": [ + "string", + "null" + ] + }, + "expert_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "uniqueItems": true, + "maxItems": 1000 + } + }, + "required": [ + "case_name", + "expert_ids" + ], + "additionalProperties": false + }, + "SolutionAssignmentOverride": { + "type": "object", + "properties": { + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expert_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "uniqueItems": true, + "maxItems": 1000 + } + }, + "required": [ + "solution_id", + "expert_ids" + ], + "additionalProperties": false + }, + "SolutionAllocationWrite": { + "type": "object", + "properties": { + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "expert_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "uniqueItems": true, + "maxItems": 1000 + }, + "solution_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "uniqueItems": true, + "maxItems": 10000 + }, + "case_pools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionCasePool" + }, + "maxItems": 1000 + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "previous_plan_token": { + "type": "string", + "maxLength": 500000 + }, + "assignment_overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionAssignmentOverride" + }, + "maxItems": 10000 + } + }, + "required": [ + "revision", + "expert_ids" + ], + "additionalProperties": false + }, + "SolutionPlannedAssignment": { + "type": "object", + "properties": { + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "solution_name": { + "type": "string" + }, + "expert_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expert_name": { + "type": "string" + }, + "case_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "solution_id", + "solution_name", + "expert_id", + "expert_name", + "case_name" + ], + "additionalProperties": false + }, + "SolutionAssignmentShortage": { + "type": "object", + "properties": { + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "solution_name": { + "type": "string" + }, + "missing": { + "type": "integer", + "minimum": 1 + }, + "case_name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "solution_id", + "solution_name", + "missing", + "case_name" + ], + "additionalProperties": false + }, + "SolutionExpertLoad": { + "type": "object", + "properties": { + "expert_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "before": { + "type": "integer", + "minimum": 0 + }, + "after": { + "type": "integer", + "minimum": 0 + }, + "assigned_before": { + "type": "integer", + "minimum": 0 + }, + "assigned_after": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "expert_id", + "name", + "before", + "after", + "assigned_before", + "assigned_after" + ], + "additionalProperties": false + }, + "SolutionAllocationPlan": { + "type": "object", + "properties": { + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "plan_token": { + "type": "string", + "maxLength": 500000 + }, + "assignments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionPlannedAssignment" + } + }, + "shortages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionAssignmentShortage" + } + }, + "can_reroll": { + "type": "boolean" + }, + "load": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionExpertLoad" + } + } + }, + "required": [ + "revision", + "plan_token", + "assignments", + "shortages", + "can_reroll", + "load" + ], + "additionalProperties": false + }, + "SolutionAllocationApply": { + "type": "object", + "properties": { + "plan_token": { + "type": "string", + "maxLength": 500000 + } + }, + "required": [ + "plan_token" + ], + "additionalProperties": false + }, + "SolutionAssignmentReceipt": { + "type": "object", + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "created": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "program_id", + "revision", + "created" + ], + "additionalProperties": false + }, + "SolutionQuotaWrite": { + "type": "object", + "properties": { + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "required_evaluators": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "required": [ + "revision", + "required_evaluators" + ], + "additionalProperties": false + }, + "SolutionQuota": { + "type": "object", + "properties": { + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "required_evaluators": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "submitted_solutions": { + "type": "integer", + "minimum": 0 + }, + "solutions_missing_experts": { + "type": "integer", + "minimum": 0 + }, + "ready_before": { + "type": "integer", + "minimum": 0 + }, + "ready_after": { + "type": "integer", + "minimum": 0 + }, + "missing_assignments": { + "type": "integer", + "minimum": 0 + }, + "surplus_assignments": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "revision", + "required_evaluators", + "submitted_solutions", + "solutions_missing_experts", + "ready_before", + "ready_after", + "missing_assignments", + "surplus_assignments" + ], + "additionalProperties": false + }, + "ResultCriterion": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "float", + "bool", + "str" + ] + }, + "min_value": { + "type": [ + "number", + "null" + ] + }, + "max_value": { + "type": [ + "number", + "null" + ] + } + }, + "required": [ + "id", + "name", + "description", + "type", + "min_value", + "max_value" + ], + "additionalProperties": false + }, + "ResultExpert": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "avatar": { + "type": [ + "string", + "null" + ] + }, + "complete": { + "type": "boolean" + }, + "total": { + "type": [ + "number", + "null" + ] + }, + "values": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RatingValue" + } + } + }, + "required": [ + "user_id", + "name", + "email", + "avatar", + "complete", + "total", + "values" + ], + "additionalProperties": false + }, + "SolutionResult": { + "type": "object", + "properties": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "solution_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "integer", + "minimum": 0 + }, + "required": { + "type": "integer", + "minimum": 1 + }, + "evaluation_complete": { + "type": "boolean" + }, + "ready": { + "type": "boolean" + }, + "score": { + "type": [ + "number", + "null" + ] + }, + "place": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "averages": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "experts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultExpert" + } + } + }, + "required": [ + "application", + "solution_id", + "name", + "case_name", + "completed", + "required", + "evaluation_complete", + "ready", + "score", + "place", + "averages", + "experts" + ], + "additionalProperties": false + }, + "SolutionResultGroup": { + "type": "object", + "properties": { + "case_name": { + "type": [ + "string", + "null" + ] + }, + "solutions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionResult" + } + } + }, + "required": [ + "case_name", + "solutions" + ], + "additionalProperties": false + }, + "SolutionResults": { + "type": "object", + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "program_name": { + "type": "string" + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "required_evaluators": { + "type": "integer", + "minimum": 1 + }, + "maximum": { + "type": [ + "number", + "null" + ] + }, + "has_numeric_criteria": { + "type": "boolean" + }, + "case_options": { + "type": "array", + "items": { + "type": "string" + } + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionResultGroup" + } + }, + "criteria": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultCriterion" + } + } + }, + "required": [ + "program_id", + "program_name", + "generated_at", + "required_evaluators", + "maximum", + "has_numeric_criteria", + "case_options", + "groups", + "criteria" + ], + "additionalProperties": false + }, + "CabinetApplication": { + "type": "object", + "properties": { + "application": { + "$ref": "#/components/schemas/ApplicationRef" + }, + "assignment_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "program": { + "$ref": "#/components/schemas/ProgramRef" + }, + "deadline": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "is_completed": { + "type": "boolean" + }, + "can_rate": { + "type": "boolean" + }, + "blocked_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "application", + "assignment_id", + "program", + "deadline", + "case_name", + "is_completed", + "can_rate", + "blocked_reason" + ], + "additionalProperties": false + }, + "CabinetApplications": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + }, + "unavailable": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CabinetApplication" + } + } + }, + "required": [ + "count", + "limit", + "offset", + "pending", + "completed", + "unavailable", + "results" + ], + "additionalProperties": false + }, + "WidgetProjectRef": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "program_link_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "name", + "program_link_id" + ] + }, + "WidgetStage": { + "type": "string", + "enum": [ + "none", + "not_submitted", + "submitted", + "review", + "evaluated", + "not_applicable" + ] + }, + "WidgetLegacyParticipant": { + "type": "object", + "additionalProperties": false, + "properties": { + "participant_project": { + "anyOf": [ + { + "$ref": "#/components/schemas/WidgetProjectRef" + }, + { + "type": "null" + } + ] + }, + "case_provided": { + "type": "boolean" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "stage": { + "$ref": "#/components/schemas/WidgetStage" + }, + "submission_open": { + "type": "boolean" + } + }, + "required": [ + "participant_project", + "case_provided", + "case_name", + "stage", + "submission_open" + ] + }, + "WidgetParticipant": { + "type": "object", + "additionalProperties": false, + "properties": { + "application": { + "anyOf": [ + { + "$ref": "#/components/schemas/SolutionRef" + }, + { + "type": "null" + } + ] + }, + "case_provided": { + "type": "boolean" + }, + "case_name": { + "type": [ + "string", + "null" + ] + }, + "stage": { + "$ref": "#/components/schemas/WidgetStage" + }, + "submission_open": { + "type": "boolean" + } + }, + "required": [ + "application", + "case_provided", + "case_name", + "stage", + "submission_open" + ] + }, + "WidgetLegacyOrganizer": { + "type": "object", + "additionalProperties": false, + "properties": { + "participants": { + "type": "integer", + "minimum": 0 + }, + "projects": { + "type": "integer", + "minimum": 0 + }, + "submitted_solutions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "participants_without_project": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "participants", + "projects", + "submitted_solutions", + "participants_without_project" + ] + }, + "WidgetOrganizer": { + "type": "object", + "additionalProperties": false, + "properties": { + "participants": { + "type": "integer", + "minimum": 0 + }, + "solutions": { + "type": "integer", + "minimum": 0 + }, + "submitted_solutions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "participants_without_solution": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "participants", + "solutions", + "submitted_solutions", + "participants_without_solution" + ] + }, + "WidgetExpert": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "open", + "distributed" + ] + }, + "assigned": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "remaining": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "evaluation_ends": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "required": [ + "mode", + "assigned", + "remaining", + "evaluation_ends" + ] + }, + "SolutionWidget": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "participant" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "solution" + }, + "participant": { + "$ref": "#/components/schemas/WidgetParticipant" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "storage_mode", + "participant" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "organizer" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "solution" + }, + "organizer": { + "$ref": "#/components/schemas/WidgetOrganizer" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "storage_mode", + "organizer" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "expert" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "solution" + }, + "expert": { + "$ref": "#/components/schemas/WidgetExpert" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "storage_mode", + "expert" + ] + } + ] + }, + "LegacyWidget": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "participant" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "legacy_project" + }, + "participant": { + "$ref": "#/components/schemas/WidgetLegacyParticipant" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "participant" + ] }, - "last_name": { + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "organizer" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "legacy_project" + }, + "organizer": { + "$ref": "#/components/schemas/WidgetLegacyOrganizer" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "organizer" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "role": { + "const": "expert" + }, + "is_competitive": { + "type": "boolean" + }, + "storage_mode": { + "const": "legacy_project" + }, + "expert": { + "$ref": "#/components/schemas/WidgetExpert" + } + }, + "required": [ + "program_id", + "role", + "is_competitive", + "expert" + ] + } + ] + }, + "RoleWidget": { + "oneOf": [ + { + "$ref": "#/components/schemas/SolutionWidget" + }, + { + "$ref": "#/components/schemas/LegacyWidget" + } + ] + }, + "WidgetError": { + "type": "object", + "properties": { + "detail": { "type": "string" + } + }, + "required": [ + "detail" + ], + "additionalProperties": true, + "description": "Существующий DRF error contract виджета; raw body не показывается в Angular." + }, + "CaseSolutionMetrics": { + "type": "object", + "required": [ + "participants_total", + "solutions_total", + "not_submitted", + "submitted" + ], + "properties": { + "participants_total": { + "type": "integer", + "minimum": 0 }, - "avatar_url": { + "solutions_total": { + "type": "integer", + "minimum": 0 + }, + "not_submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "additionalProperties": false + }, + "CaseSolutionItem": { + "type": "object", + "required": [ + "name", + "participants_total", + "solutions_total", + "not_submitted", + "submitted" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "participants_total": { + "type": "integer", + "minimum": 0 + }, + "solutions_total": { + "type": "integer", + "minimum": 0 + }, + "not_submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "additionalProperties": false + }, + "CaseSolutionAnalytics": { + "type": "object", + "required": [ + "program_id", + "configured", + "submission_applicable", + "items", + "without_case" + ], + "properties": { + "program_id": { + "type": "integer", + "minimum": 1 + }, + "configured": { + "type": "boolean" + }, + "submission_applicable": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CaseSolutionItem" + } + }, + "without_case": { + "$ref": "#/components/schemas/CaseSolutionMetrics" + } + }, + "additionalProperties": false + }, + "ManagerTotal": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "total" + ], + "additionalProperties": false + }, + "ManagerRegion": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "name", + "count" + ], + "additionalProperties": false + }, + "ManagerRegions": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerRegion" + } + } + }, + "required": [ + "total", + "items" + ], + "additionalProperties": false + }, + "ManagerSummary": { + "type": "object", + "properties": { + "participants": { + "$ref": "#/components/schemas/ManagerTotal" + }, + "solutions": { + "$ref": "#/components/schemas/ManagerTotal" + }, + "experts": { + "$ref": "#/components/schemas/ManagerTotal" + }, + "participant_regions": { + "$ref": "#/components/schemas/ManagerRegions" + } + }, + "required": [ + "participants", + "solutions", + "experts", + "participant_regions" + ], + "additionalProperties": false + }, + "ManagerParticipantFunnel": { + "type": "object", + "properties": { + "registrations": { + "type": "integer", + "minimum": 0 + }, + "unique_participants": { + "type": "integer", + "minimum": 0 + }, + "visited_participants": { + "type": "integer", + "minimum": 0 + }, + "onboarded_participants": { + "type": "integer", + "minimum": 0 + }, + "with_team": { + "type": "integer", + "minimum": 0 + }, + "captains": { + "type": "integer", + "minimum": 0 + }, + "submitted_captains": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "registrations", + "unique_participants", + "visited_participants", + "onboarded_participants", + "with_team", + "captains", + "submitted_captains" + ], + "additionalProperties": false + }, + "ManagerSolutionFunnel": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "submission_applicable": { + "type": "boolean" + }, + "not_submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "evaluated": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "created", + "submission_applicable", + "not_submitted", + "submitted", + "evaluated" + ], + "additionalProperties": false + }, + "ManagerEvaluationCounters": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "evaluated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "total", + "pending", + "evaluated" + ], + "additionalProperties": false + }, + "ManagerEvaluationSolutions": { + "type": "object", + "properties": { + "submitted": { + "type": "integer", + "minimum": 0 + }, + "awaiting_evaluation": { + "type": "integer", + "minimum": 0 + }, + "partially_evaluated": { + "type": "integer", + "minimum": 0 + }, + "evaluated": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "submitted", + "awaiting_evaluation", + "partially_evaluated", + "evaluated" + ], + "additionalProperties": false + }, + "ManagerEvaluationStatus": { + "type": "object", + "properties": { + "applicable": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "open", + "distributed" + ] + }, + "required_evaluators": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "max_evaluations_per_solution": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "assignments": { "anyOf": [ { - "type": "string", - "format": "uri", - "pattern": "^https?://" + "$ref": "#/components/schemas/ManagerEvaluationCounters" + }, + { + "type": "null" + } + ] + }, + "solutions": { + "anyOf": [ + { + "$ref": "#/components/schemas/ManagerEvaluationSolutions" }, { "type": "null" @@ -5836,1164 +13381,1894 @@ } }, "required": [ - "id", + "applicable", + "mode", + "required_evaluators", + "max_evaluations_per_solution", + "assignments", + "solutions" + ], + "additionalProperties": false + }, + "ManagerExpert": { + "type": "object", + "properties": { + "expert_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "avatar": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "expert_id", + "user_id", "first_name", "last_name", - "avatar_url" + "full_name", + "avatar" ], "additionalProperties": false }, - "ProgramRef": { + "ManagerAssignment": { "type": "object", "properties": { - "id": { + "assignment_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "expert": { + "$ref": "#/components/schemas/ManagerExpert" + }, + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "status": { + "type": "string", + "enum": [ + "not_ready", + "pending", + "in_progress", + "completed" + ] + }, + "assigned_at": { + "type": "string", + "format": "date-time" + }, + "submitted": { + "type": "boolean" + }, + "submitted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "assignment_id", + "expert", + "application", + "status", + "assigned_at", + "submitted", + "submitted_at", + "waiting_since", + "waiting_seconds" + ], + "additionalProperties": false + }, + "ManagerAssignmentCriterion": { + "type": "object", + "properties": { "name": { "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "int", + "float", + "bool", + "str" + ] + }, + "min_value": { + "type": [ + "number", + "null" + ] + }, + "max_value": { + "type": [ + "number", + "null" + ] + }, + "value": { + "type": [ + "string", + "null" + ], + "maxLength": 1000, + "description": "Строка/null по прежнему типу критерия. Текстовый комментарий допускает до 1000 символов, как текущий ProjectScore; числовой диапазон и True/False продолжают проверяться отдельно." + }, + "criterion_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "is_scored": { + "type": "boolean" } }, "required": [ - "id", - "name" + "name", + "description", + "type", + "min_value", + "max_value", + "value", + "criterion_id", + "is_scored" ], "additionalProperties": false }, - "SolutionRef": { + "ManagerAssignmentDetail": { "type": "object", "properties": { - "kind": { - "const": "solution" + "assignment_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expert": { + "$ref": "#/components/schemas/ManagerExpert" + }, + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "status": { + "type": "string", + "enum": [ + "not_ready", + "pending", + "in_progress", + "completed" + ] + }, + "assigned_at": { + "type": "string", + "format": "date-time" + }, + "submitted": { + "type": "boolean" + }, + "submitted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "scores": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerAssignmentCriterion" + } + } + }, + "required": [ + "assignment_id", + "expert", + "application", + "status", + "assigned_at", + "submitted", + "submitted_at", + "waiting_since", + "waiting_seconds", + "scores" + ], + "additionalProperties": false + }, + "ManagerAssignments": { + "type": "object", + "properties": { + "applicable": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "open", + "distributed" + ] + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerAssignment" + } + } + }, + "required": [ + "applicable", + "mode", + "results" + ], + "additionalProperties": false + }, + "ManagerDelayedExpert": { + "type": "object", + "properties": { + "expert_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "avatar": { + "type": [ + "string", + "null" + ] + }, + "assignments_total": { + "type": "integer", + "minimum": 0 + }, + "completed": { + "type": "integer", + "minimum": 0 + }, + "pending": { + "type": "integer", + "minimum": 0 + }, + "overdue_24h": { + "type": "integer", + "minimum": 0 + }, + "overdue_48h": { + "type": "integer", + "minimum": 0 + }, + "oldest_waiting_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "oldest_waiting_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "warning" + ] + } + }, + "required": [ + "expert_id", + "user_id", + "first_name", + "last_name", + "full_name", + "avatar", + "assignments_total", + "completed", + "pending", + "overdue_24h", + "overdue_48h", + "oldest_waiting_since", + "oldest_waiting_seconds", + "severity" + ], + "additionalProperties": false + }, + "ManagerDelayedExperts": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerDelayedExpert" + } + } + }, + "required": [ + "total", + "items" + ], + "additionalProperties": false + }, + "ManagerAttention": { + "type": "object", + "properties": { + "participants_without_visit": { + "type": "integer", + "minimum": 0 + }, + "participants_without_onboarding": { + "type": "integer", + "minimum": 0 + }, + "participants_without_team": { + "type": "integer", + "minimum": 0 + }, + "solutions_awaiting_evaluation": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "solutions_not_submitted": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "delayed_experts": { + "$ref": "#/components/schemas/ManagerDelayedExperts" + } + }, + "required": [ + "participants_without_visit", + "participants_without_onboarding", + "participants_without_team", + "solutions_awaiting_evaluation", + "solutions_not_submitted", + "delayed_experts" + ], + "additionalProperties": false + }, + "ManagerActivity": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "registrations": { + "type": "integer", + "minimum": 0 + }, + "submitted_solutions": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "date", + "registrations", + "submitted_solutions" + ], + "additionalProperties": false + }, + "ManagerFieldCoverage": { + "type": "object", + "properties": { + "field_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "label": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/FieldType" + }, + "purpose": { + "$ref": "#/components/schemas/Purpose" }, - "program_id": { + "applicable": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 0 }, - "solution_id": { + "answered": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 0 }, - "name": { - "type": "string" + "required_applicable": { + "type": "integer", + "minimum": 0 + }, + "required_unanswered": { + "type": "integer", + "minimum": 0 } }, "required": [ - "kind", - "program_id", - "solution_id", - "name" + "field_id", + "label", + "type", + "purpose", + "applicable", + "answered", + "required_applicable", + "required_unanswered" ], "additionalProperties": false }, - "LegacyRef": { + "ManagerAnalytics": { "type": "object", "properties": { - "kind": { - "const": "legacy_project" - }, "program_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "project_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "summary": { + "$ref": "#/components/schemas/ManagerSummary" }, - "program_link_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "participant_funnel": { + "$ref": "#/components/schemas/ManagerParticipantFunnel" }, - "name": { - "type": "string" + "solution_funnel": { + "$ref": "#/components/schemas/ManagerSolutionFunnel" + }, + "evaluation_status": { + "$ref": "#/components/schemas/ManagerEvaluationStatus" + }, + "attention": { + "$ref": "#/components/schemas/ManagerAttention" + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerActivity" + } + }, + "cases": { + "$ref": "#/components/schemas/CaseSolutionAnalytics" + }, + "field_coverage": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerFieldCoverage" + } } }, "required": [ - "kind", "program_id", - "project_id", - "program_link_id", - "name" + "summary", + "participant_funnel", + "solution_funnel", + "evaluation_status", + "attention", + "activity", + "cases", + "field_coverage" ], "additionalProperties": false }, - "ApplicationRef": { - "oneOf": [ - { - "$ref": "#/components/schemas/SolutionRef" - }, - { - "$ref": "#/components/schemas/LegacyRef" - } - ] - }, - "Issue": { + "ManagerSolutionRow": { "type": "object", "properties": { - "path": { - "type": "string" - }, - "code": { - "type": "string" + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "field_id": { - "anyOf": [ - { + "captain": { + "type": "object", + "properties": { + "user_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - { - "type": "null" + "full_name": { + "type": "string" + }, + "avatar": { + "type": [ + "string", + "null" + ] } + }, + "required": [ + "user_id", + "full_name", + "avatar" + ], + "additionalProperties": false + }, + "case_name": { + "type": [ + "string", + "null" ] }, - "detail": { - "type": "string" - } - }, - "required": [ - "path", - "code", - "field_id", - "detail" - ], - "additionalProperties": false - }, - "Error": { - "type": "object", - "properties": { - "code": { - "type": "string" + "submission_status": { + "type": "string", + "enum": [ + "draft", + "submitted" + ] }, - "detail": { - "type": "string" + "evaluation_status": { + "type": "string", + "enum": [ + "not_applicable", + "not_submitted", + "awaiting_evaluation", + "partially_evaluated", + "evaluated" + ] }, - "errors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Issue" - } + "created_at": { + "type": "string", + "format": "date-time" }, - "meta": { - "type": "object", - "additionalProperties": true + "submitted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "assignments_total": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "assignments_completed": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "waiting_reason": { + "type": [ + "string", + "null" + ], + "enum": [ + null, + "no_assignments", + "no_completed_evaluations", + "partially_evaluated", + "awaiting_first_evaluation" + ] } }, "required": [ - "code", - "detail", - "errors", - "meta" + "application", + "captain", + "case_name", + "submission_status", + "evaluation_status", + "created_at", + "submitted_at", + "assignments_total", + "assignments_completed", + "waiting_reason" ], "additionalProperties": false }, - "Context": { + "ManagerSolutions": { "type": "object", "properties": { - "program_id": { + "count": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "storage_mode": { - "enum": [ - "legacy_project", - "solution" - ] - }, - "is_registered": { - "type": "boolean" - }, - "is_competitive": { - "type": "boolean" + "minimum": 0 }, - "submission_open": { - "type": "boolean" + "next": { + "type": [ + "string", + "null" + ], + "format": "uri" }, - "submission_deadline": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ] + "previous": { + "type": [ + "string", + "null" + ], + "format": "uri" }, - "can_create": { + "submission_applicable": { "type": "boolean" }, - "blocked_reason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "mode": { + "type": "string", + "enum": [ + "open", + "distributed" ] }, - "current_application": { - "anyOf": [ - { - "$ref": "#/components/schemas/ApplicationRef" - }, - { - "type": "null" - } - ] + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManagerSolutionRow" + } } }, "required": [ - "program_id", - "storage_mode", - "is_registered", - "is_competitive", - "submission_open", - "submission_deadline", - "can_create", - "blocked_reason", - "current_application" + "count", + "next", + "previous", + "submission_applicable", + "mode", + "results" ], "additionalProperties": false }, - "Validation": { + "SolutionReminderSelection": { "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], "properties": { - "min_length": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } + "mode": { + "type": "string", + "enum": [ + "selected", + "all" ] }, - "max_length": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ] + "ids": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "default": [] }, - "minimum": { - "anyOf": [ - { - "type": "number" + "excluded_ids": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "default": [] + } + }, + "oneOf": [ + { + "required": [ + "mode", + "ids" + ], + "properties": { + "mode": { + "const": "selected" }, - { - "type": "null" - } - ] - }, - "maximum": { - "anyOf": [ - { - "type": "number" + "ids": { + "type": "array", + "maxItems": 10000, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1 }, - { - "type": "null" + "excluded_ids": { + "type": "array", + "maxItems": 0, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "default": [] } - ] - }, - "allowed_extensions": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[a-z0-9]+$" } }, - "max_file_size_bytes": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 104857600 + { + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "all" }, - { - "type": "null" + "ids": { + "type": "array", + "maxItems": 0, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "default": [] } - ] + } } - }, - "required": [ - "min_length", - "max_length", - "minimum", - "maximum", - "allowed_extensions", - "max_file_size_bytes" - ], - "additionalProperties": false - }, - "FieldType": { - "enum": [ - "text", - "textarea", - "url", - "number", - "select", - "checkbox", - "file", - "radio" - ] - }, - "Purpose": { - "enum": [ - "other", - "case", - "presentation", - "repository", - "demo", - "video", - "run_instructions" ] }, - "TemplateName": { - "enum": [ - "case_championship", - "hackathon" - ] + "SolutionReminderPreviewRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "selection" + ], + "properties": { + "search": { + "type": "string", + "maxLength": 200, + "default": "" + }, + "selection": { + "$ref": "#/components/schemas/SolutionReminderSelection" + } + } }, - "Field": { + "SolutionReminderRecipient": { "type": "object", + "additionalProperties": false, + "required": [ + "user_id", + "full_name" + ], "properties": { - "id": { + "user_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "type": { - "$ref": "#/components/schemas/FieldType" - }, - "purpose": { - "$ref": "#/components/schemas/Purpose" - }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "full_name": { + "type": "string" + } + } + }, + "SolutionReminderPreview": { + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "selected_count", + "recipient_count", + "without_assignments_count", + "unavailable_count", + "preview_text", + "recipients", + "expires_in" + ], + "properties": { + "token": { + "type": [ + "string", + "null" + ], + "description": "Непрозрачный подписанный token, TTL 900 секунд; клиент не разбирает содержимое." + }, + "selected_count": { + "type": "integer", + "minimum": 0 }, - "help_text": { - "type": "string", - "maxLength": 4000 + "recipient_count": { + "type": "integer", + "minimum": 0 }, - "required": { - "type": "boolean" + "without_assignments_count": { + "type": "integer", + "minimum": 0 }, - "position": { + "unavailable_count": { "type": "integer", "minimum": 0 }, - "options": { + "preview_text": { + "type": "string" + }, + "recipients": { "type": "array", + "maxItems": 20, "items": { - "type": "string", - "minLength": 1, - "maxLength": 1000 + "$ref": "#/components/schemas/SolutionReminderRecipient" } }, - "validation": { - "$ref": "#/components/schemas/Validation" + "expires_in": { + "type": "integer", + "const": 900 } - }, + } + }, + "SolutionReminderConfirm": { + "type": "object", + "additionalProperties": false, "required": [ - "id", - "type", - "purpose", - "label", - "help_text", - "required", - "position", - "options", - "validation" + "token" ], - "additionalProperties": false + "properties": { + "token": { + "type": "string", + "minLength": 1, + "maxLength": 1000000 + } + } }, - "FieldWrite": { + "SolutionReminderReceipt": { "type": "object", + "additionalProperties": false, + "required": [ + "created_count", + "skipped_count" + ], "properties": { - "id": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "type": { - "$ref": "#/components/schemas/FieldType" + "created_count": { + "type": "integer", + "minimum": 0 }, - "purpose": { - "$ref": "#/components/schemas/Purpose" + "skipped_count": { + "type": "integer", + "minimum": 0 + } + } + }, + "PublishedSolutionMember": { + "type": "object", + "additionalProperties": false, + "required": [ + "user", + "role", + "specialization", + "is_captain" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/User" }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "role": { + "type": [ + "string", + "null" + ] }, - "help_text": { - "type": "string", - "maxLength": 4000 + "specialization": { + "type": [ + "string", + "null" + ] }, - "required": { + "is_captain": { "type": "boolean" + } + } + }, + "PublishedSolution": { + "type": "object", + "additionalProperties": false, + "required": [ + "application", + "published_at", + "fields", + "answers", + "team" + ], + "properties": { + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "position": { - "type": "integer", - "minimum": 0 + "published_at": { + "type": "string", + "format": "date-time" }, - "options": { + "fields": { "type": "array", "items": { - "type": "string", - "minLength": 1, - "maxLength": 1000 + "$ref": "#/components/schemas/Field" } }, - "validation": { - "$ref": "#/components/schemas/Validation" + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Answer" + } + }, + "team": { + "type": "object", + "additionalProperties": false, + "required": [ + "members", + "accepted_count" + ], + "properties": { + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/PublishedSolutionMember" + } + }, + "accepted_count": { + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "PublishedSolutionCard": { + "type": "object", + "additionalProperties": false, + "required": [ + "application", + "published_at", + "captain", + "accepted_count" + ], + "properties": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "published_at": { + "type": "string", + "format": "date-time" + }, + "captain": { + "$ref": "#/components/schemas/User" + }, + "accepted_count": { + "type": "integer", + "minimum": 1 } - }, - "required": [ - "id", - "type", - "purpose", - "label", - "help_text", - "required", - "position", - "options", - "validation" - ], - "additionalProperties": false + } }, - "Form": { + "PublishedSolutionPage": { "type": "object", + "additionalProperties": false, + "required": [ + "program_id", + "count", + "next", + "previous", + "results" + ], "properties": { "program_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "revision": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" + "count": { + "type": "integer", + "minimum": 0 }, - "template": { - "anyOf": [ - { - "$ref": "#/components/schemas/TemplateName" - }, - { - "type": "null" - } + "next": { + "type": [ + "string", + "null" ] }, - "is_configured": { - "type": "boolean" - }, - "is_snapshot": { - "type": "boolean" + "previous": { + "type": [ + "string", + "null" + ] }, - "fields": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/Field" + "$ref": "#/components/schemas/PublishedSolutionCard" } } - }, - "required": [ - "program_id", - "revision", - "template", - "is_configured", - "is_snapshot", - "fields" - ], - "additionalProperties": false + } }, - "FormWrite": { + "SolutionActionPreviewRequest": { "type": "object", "properties": { - "expected_revision": { + "action": { "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, - "template": { - "$ref": "#/components/schemas/TemplateName" + "enum": [ + "submit" + ] }, - "fields": { + "ids": { "type": "array", "items": { - "$ref": "#/components/schemas/FieldWrite" - } + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000 } }, "required": [ - "expected_revision", - "template", - "fields" + "action", + "ids" ], "additionalProperties": false }, - "FormManagement": { + "SolutionActionApplyRequest": { "type": "object", "properties": { - "form": { - "$ref": "#/components/schemas/Form" - }, - "has_solutions": { - "type": "boolean" - }, - "has_answers": { - "type": "boolean" - }, - "affected_draft_count": { - "type": "integer", - "minimum": 0 + "action": { + "type": "string", + "enum": [ + "submit" + ] }, - "fields_usage": { + "ids": { "type": "array", "items": { - "type": "object", - "properties": { - "field_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "answers_count": { - "type": "integer", - "minimum": 0 - }, - "can_delete": { - "type": "boolean" - }, - "can_change_type": { - "type": "boolean" - } - }, - "required": [ - "field_id", - "answers_count", - "can_delete", - "can_change_type" - ], - "additionalProperties": false - } + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000 + }, + "preview_token": { + "type": "string", + "minLength": 1, + "maxLength": 200000 } }, "required": [ - "form", - "has_solutions", - "has_answers", - "affected_draft_count", - "fields_usage" + "action", + "ids", + "preview_token" ], "additionalProperties": false }, - "Template": { + "SolutionActionItem": { "type": "object", "properties": { - "key": { - "$ref": "#/components/schemas/TemplateName" - }, - "label": { - "type": "string" + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "fields": { + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/FieldWrite" + "$ref": "#/components/schemas/Issue" } } }, "required": [ - "key", - "label", - "fields" + "application", + "errors" ], "additionalProperties": false }, - "Templates": { + "SolutionActionPreview": { "type": "object", "properties": { - "results": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/Template" + "$ref": "#/components/schemas/SolutionActionItem" } }, - "max_file_size_bytes": { - "const": 104857600 - }, - "files_per_field": { - "const": 1 - } - }, - "required": [ - "results", - "max_file_size_bytes", - "files_per_field" - ], - "additionalProperties": false - }, - "FileRef": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "pattern": "^https?://" - } - }, - "required": [ - "url" - ], - "additionalProperties": false - }, - "AnswerValue": { - "oneOf": [ - { - "type": "string" + "ready_count": { + "type": "integer", + "minimum": 0 }, - { - "type": "number" + "already_submitted_count": { + "type": "integer", + "minimum": 0 }, - { + "can_apply": { "type": "boolean" }, - { - "$ref": "#/components/schemas/FileRef" - } - ] - }, - "Answer": { - "type": "object", - "properties": { - "field_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "value": { - "$ref": "#/components/schemas/AnswerValue" + "preview_token": { + "type": [ + "string", + "null" + ] } }, "required": [ - "field_id", - "value" + "items", + "ready_count", + "already_submitted_count", + "can_apply", + "preview_token" ], "additionalProperties": false }, - "AnswerPatch": { + "SolutionActionResult": { "type": "object", "properties": { - "field_id": { + "changed_count": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 0 }, - "value": { - "anyOf": [ - { - "$ref": "#/components/schemas/AnswerValue" - }, - { - "type": "null" - } - ] + "already_submitted_count": { + "type": "integer", + "minimum": 0 } }, "required": [ - "field_id", - "value" + "changed_count", + "already_submitted_count" ], - "additionalProperties": false - }, - "Member": { - "type": "object", - "properties": { - "user": { - "$ref": "#/components/schemas/User" - }, - "role": { - "anyOf": [ - { - "type": "string", - "maxLength": 128 - }, - { - "type": "null" - } - ] - }, - "specialization": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ] - }, - "joined_at": { + "additionalProperties": false + }, + "SolutionCaseChangePreviewRequest": { + "type": "object", + "properties": { + "case_name": { "type": "string", - "format": "date-time" + "minLength": 1, + "maxLength": 1000, + "pattern": "\\S" + } + }, + "required": [ + "case_name" + ], + "additionalProperties": false + }, + "SolutionCaseChangeApplyRequest": { + "type": "object", + "properties": { + "case_name": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "pattern": "\\S" }, - "is_captain": { - "type": "boolean" + "preview_token": { + "type": "string", + "minLength": 1, + "maxLength": 200000 } }, "required": [ - "user", - "role", - "specialization", - "joined_at", - "is_captain" + "case_name", + "preview_token" ], "additionalProperties": false }, - "Team": { + "SolutionCaseChangePreview": { "type": "object", "properties": { - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Member" - } + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "accepted_count": { + "case_field_id": { "type": "integer", - "minimum": 0 + "minimum": 1, + "maximum": 9007199254740991 }, - "pending_count": { - "type": "integer", - "minimum": 0 + "current_case": { + "type": [ + "string", + "null" + ] }, - "reserved_count": { + "case_name": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "pattern": "\\S" + }, + "solution_version": { "type": "integer", - "minimum": 0 + "minimum": 1 }, - "min_size": { - "anyOf": [ - { - "type": "integer", - "minimum": 1 - }, - { - "type": "null" - } - ] + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } }, - "max_size": { - "anyOf": [ - { - "type": "integer", - "minimum": 1 - }, - { - "type": "null" - } + "can_apply": { + "type": "boolean" + }, + "preview_token": { + "type": [ + "string", + "null" ] }, - "is_frozen": { + "is_submitted": { "type": "boolean" + }, + "removed_assignments_count": { + "type": "integer", + "minimum": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 } }, "required": [ - "members", - "accepted_count", - "pending_count", - "reserved_count", - "min_size", - "max_size", - "is_frozen" + "application", + "case_field_id", + "current_case", + "case_name", + "solution_version", + "errors", + "can_apply", + "preview_token", + "is_submitted", + "removed_assignments_count", + "score_count" ], "additionalProperties": false }, - "Permissions": { - "type": "object", - "properties": { - "can_edit": { - "type": "boolean" + "SolutionAdminChange": { + "oneOf": [ + { + "$ref": "#/components/schemas/SolutionCaseAdminChange" }, - "can_manage_team": { - "type": "boolean" + { + "$ref": "#/components/schemas/SolutionMemberAdminChange" }, - "can_invite": { - "type": "boolean" + { + "$ref": "#/components/schemas/SolutionArchiveAdminChange" }, - "can_submit": { - "type": "boolean" + { + "$ref": "#/components/schemas/SolutionRegistrationAdminChange" } - }, - "required": [ - "can_edit", - "can_manage_team", - "can_invite", - "can_submit" ], - "additionalProperties": false + "discriminator": { + "propertyName": "action" + } }, - "Submission": { + "SolutionAdminChangePage": { "type": "object", + "additionalProperties": false, + "required": [ + "count", + "offset", + "limit", + "results" + ], "properties": { - "required": { - "type": "boolean" + "count": { + "type": "integer", + "minimum": 0 }, - "status": { - "enum": [ - "draft", - "submitted" - ] + "offset": { + "type": "integer", + "minimum": 0 }, - "submission_open": { - "type": "boolean" + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 }, - "deadline": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionAdminChange" + } + } + } + }, + "SolutionAdminChangeDetail": { + "type": "object", + "additionalProperties": false, + "required": [ + "change", + "before_snapshot", + "after_snapshot" + ], + "properties": { + "change": { + "$ref": "#/components/schemas/SolutionAdminChange" + }, + "before_snapshot": { "anyOf": [ { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/SubmissionSnapshot" }, { "type": "null" } ] }, - "submitted_at": { + "after_snapshot": { "anyOf": [ { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/SubmissionSnapshot" }, { "type": "null" } ] + }, + "archive_snapshot": { + "$ref": "#/components/schemas/SolutionArchiveSnapshot" + } + } + }, + "SolutionMemberRemovalPreviewRequest": { + "type": "object", + "properties": { + "user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } }, "required": [ - "required", - "status", - "submission_open", - "deadline", - "submitted_at" + "user_id" ], "additionalProperties": false }, - "Readiness": { + "SolutionMemberRemovalApplyRequest": { "type": "object", "properties": { - "is_ready": { - "type": "boolean" + "user_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, - "issues": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Issue" - } + "preview_token": { + "type": "string", + "minLength": 1, + "maxLength": 200000 } }, "required": [ - "is_ready", - "issues" + "user_id", + "preview_token" ], "additionalProperties": false }, - "Solution": { + "SolutionMemberRemovalPreview": { "type": "object", "properties": { - "id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "program_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "member": { + "$ref": "#/components/schemas/Member" }, - "version": { + "solution_version": { "type": "integer", "minimum": 1 }, - "name": { - "type": "string", - "maxLength": 256 + "is_submitted": { + "type": "boolean" }, - "captain_id": { + "remaining_member_count": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "minimum": 0 }, - "team": { - "$ref": "#/components/schemas/Team" + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 }, - "form": { - "$ref": "#/components/schemas/Form" + "score_count": { + "type": "integer", + "minimum": 0 }, - "answers": { + "errors": { "type": "array", "items": { - "$ref": "#/components/schemas/Answer" + "$ref": "#/components/schemas/Issue" } }, - "submission": { - "$ref": "#/components/schemas/Submission" - }, - "permissions": { - "$ref": "#/components/schemas/Permissions" + "can_apply": { + "type": "boolean" }, - "readiness": { - "anyOf": [ - { - "$ref": "#/components/schemas/Readiness" - }, - { - "type": "null" - } + "preview_token": { + "type": [ + "string", + "null" ] - } - }, - "required": [ - "id", - "program_id", - "version", - "name", - "captain_id", - "created_at", - "updated_at", - "team", - "form", - "answers", - "submission", - "permissions", - "readiness" + } + }, + "required": [ + "application", + "member", + "solution_version", + "is_submitted", + "remaining_member_count", + "revoked_invitation_count", + "score_count", + "errors", + "can_apply", + "preview_token" ], "additionalProperties": false }, - "SolutionSummary": { + "SolutionCaseAdminChange": { "type": "object", + "additionalProperties": false, + "required": [ + "id", + "application", + "action", + "actor", + "actor_name", + "created_at", + "before_version", + "after_version", + "case_field_id", + "previous_case", + "case_name", + "removed_assignments_count", + "score_count", + "was_submitted" + ], "properties": { "id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "program_id": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "name": { - "type": "string" - }, - "captain": { - "$ref": "#/components/schemas/User" + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "accepted_count": { - "type": "integer", - "minimum": 0 + "action": { + "const": "change_case" }, - "case_value": { + "actor": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/User" }, { "type": "null" } ] }, - "status": { - "enum": [ - "draft", - "submitted" + "actor_name": { + "type": "string", + "maxLength": 512 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "before_version": { + "type": "integer", + "minimum": 1 + }, + "after_version": { + "type": "integer", + "minimum": 2 + }, + "case_field_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "previous_case": { + "type": [ + "string", + "null" ] }, - "submitted_at": { + "case_name": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "removed_assignments_count": { + "type": "integer", + "minimum": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "was_submitted": { + "type": "boolean" + } + } + }, + "SolutionMemberAdminChange": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "action": { + "const": "remove_member" + }, + "actor": { "anyOf": [ { - "type": "string", - "format": "date-time" + "$ref": "#/components/schemas/User" }, { "type": "null" } ] + }, + "actor_name": { + "type": "string", + "maxLength": 512 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "before_version": { + "type": "integer", + "minimum": 1 + }, + "after_version": { + "type": "integer", + "minimum": 2 + }, + "removed_assignments_count": { + "const": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "was_submitted": { + "type": "boolean" + }, + "removed_member": { + "$ref": "#/components/schemas/Member" + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 } }, "required": [ "id", - "program_id", - "name", - "captain", - "accepted_count", - "case_value", - "status", - "submitted_at" + "application", + "action", + "actor", + "actor_name", + "created_at", + "before_version", + "after_version", + "removed_assignments_count", + "score_count", + "was_submitted", + "removed_member", + "revoked_invitation_count" ], "additionalProperties": false }, - "CreateSolution": { + "SolutionParticipation": { "type": "object", "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 256 + "status": { + "enum": [ + "active", + "archived" + ] + }, + "archived_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "reason": { + "enum": [ + "detached", + "registration_removed", + null + ] } }, - "required": [], + "required": [ + "status", + "archived_at", + "reason" + ], "additionalProperties": false }, - "UpdateSolution": { + "SolutionArchiveSnapshot": { "type": "object", + "additionalProperties": false, + "required": [ + "name", + "captain_id", + "members", + "form", + "answers", + "captured_at" + ], "properties": { - "expected_version": { - "type": "integer", - "minimum": 1 - }, - "expected_form_revision": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" - }, "name": { "type": "string", - "minLength": 1, "maxLength": 256 }, + "captain_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" + } + }, + "form": { + "$ref": "#/components/schemas/Form" + }, "answers": { "type": "array", "items": { - "$ref": "#/components/schemas/AnswerPatch" + "$ref": "#/components/schemas/Answer" } + }, + "captured_at": { + "type": "string", + "format": "date-time" + } + } + }, + "SolutionArchivePreviewRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000, + "uniqueItems": true } }, "required": [ - "expected_version", - "expected_form_revision" + "ids" ], - "additionalProperties": false, - "anyOf": [ - { - "required": [ - "name" - ] + "additionalProperties": false + }, + "SolutionArchiveApplyRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000, + "uniqueItems": true }, - { - "required": [ - "answers" - ] + "preview_token": { + "type": "string", + "minLength": 1, + "maxLength": 200000 } - ] + }, + "required": [ + "ids", + "preview_token" + ], + "additionalProperties": false }, - "Submit": { + "SolutionArchivePreviewItem": { "type": "object", "properties": { - "expected_version": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "solution_version": { "type": "integer", "minimum": 1 }, - "expected_form_revision": { - "type": "string", - "pattern": "^[a-f0-9]{64}$" + "is_submitted": { + "type": "boolean" + }, + "is_archived": { + "type": "boolean" + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "removed_assignments_count": { + "type": "integer", + "minimum": 0 + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } } }, "required": [ - "expected_version", - "expected_form_revision" + "application", + "solution_version", + "is_submitted", + "is_archived", + "score_count", + "removed_assignments_count", + "revoked_invitation_count", + "errors" ], "additionalProperties": false }, - "CaptainCommand": { + "SolutionArchivePreview": { "type": "object", "properties": { - "expected_solution_version": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionArchivePreviewItem" + } + }, + "score_count": { "type": "integer", - "minimum": 1 + "minimum": 0 }, - "new_captain_user_id": { + "removed_assignments_count": { "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "minimum": 0 + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "can_apply": { + "type": "boolean" + }, + "preview_token": { + "type": [ + "string", + "null" + ] } }, "required": [ - "expected_solution_version", - "new_captain_user_id" + "items", + "score_count", + "removed_assignments_count", + "revoked_invitation_count", + "can_apply", + "preview_token" ], "additionalProperties": false }, - "TeamCommand": { + "SolutionArchiveResult": { "type": "object", "properties": { - "expected_solution_version": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "solution_version": { "type": "integer", "minimum": 1 + }, + "archived_at": { + "type": "string", + "format": "date-time" } }, "required": [ - "expected_solution_version" + "application", + "solution_version", + "archived_at" ], "additionalProperties": false }, - "InvitationPermissions": { + "SolutionArchiveReceipt": { "type": "object", "properties": { - "can_edit": { - "type": "boolean" - }, - "can_revoke": { - "type": "boolean" - }, - "can_accept": { - "type": "boolean" + "changed_count": { + "type": "integer", + "minimum": 0 }, - "can_decline": { - "type": "boolean" + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionArchiveResult" + } } }, "required": [ - "can_edit", - "can_revoke", - "can_accept", - "can_decline" + "changed_count", + "items" ], "additionalProperties": false }, - "Invitation": { + "SolutionArchiveAdminChange": { "type": "object", "properties": { "id": { @@ -7001,439 +15276,515 @@ "minimum": 1, "maximum": 9007199254740991 }, - "version": { - "type": "integer", - "minimum": 1 - }, - "program": { - "$ref": "#/components/schemas/ProgramRef" - }, - "solution": { + "application": { "$ref": "#/components/schemas/SolutionRef" }, - "captain": { - "$ref": "#/components/schemas/User" - }, - "recipient": { - "$ref": "#/components/schemas/User" - }, - "role": { - "anyOf": [ - { - "type": "string", - "maxLength": 128 - }, - { - "type": "null" - } - ] - }, - "specialization": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ] + "action": { + "const": "archive" }, - "motivational_letter": { + "actor": { "anyOf": [ { - "type": "string", - "maxLength": 4096 + "$ref": "#/components/schemas/User" }, { "type": "null" } ] }, - "status": { - "enum": [ - "pending", - "accepted", - "declined" - ] + "actor_name": { + "type": "string", + "maxLength": 512 }, "created_at": { "type": "string", "format": "date-time" }, - "updated_at": { + "before_version": { + "type": "integer", + "minimum": 1 + }, + "after_version": { + "type": "integer", + "minimum": 2 + }, + "removed_assignments_count": { + "type": "integer", + "minimum": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "was_submitted": { + "type": "boolean" + }, + "archived_at": { "type": "string", "format": "date-time" }, - "permissions": { - "$ref": "#/components/schemas/InvitationPermissions" + "reason": { + "enum": [ + "detached", + "registration_removed" + ] + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "registration_removal_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } }, "required": [ "id", - "version", - "program", - "solution", - "captain", - "recipient", - "role", - "specialization", - "motivational_letter", - "status", + "application", + "action", + "actor", + "actor_name", "created_at", - "updated_at", - "permissions" + "before_version", + "after_version", + "removed_assignments_count", + "score_count", + "was_submitted", + "archived_at", + "reason", + "revoked_invitation_count" ], "additionalProperties": false }, - "CreateInvitation": { + "SolutionRegistrationRemovalPreviewRequest": { "type": "object", "properties": { - "expected_solution_version": { - "type": "integer", - "minimum": 1 + "registration_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000, + "uniqueItems": true + } + }, + "required": [ + "registration_ids" + ], + "additionalProperties": false + }, + "SolutionRegistrationRemovalApplyRequest": { + "type": "object", + "properties": { + "registration_ids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "minItems": 1, + "maxItems": 10000, + "uniqueItems": true }, - "recipient_user_id": { + "preview_token": { + "type": "string", + "minLength": 1, + "maxLength": 200000 + } + }, + "required": [ + "registration_ids", + "preview_token" + ], + "additionalProperties": false + }, + "SolutionRemovedRegistration": { + "type": "object", + "properties": { + "registration_id": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "role": { - "anyOf": [ - { - "type": "string", - "maxLength": 128 - }, - { - "type": "null" - } - ] - }, - "specialization": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } - ] - }, - "motivational_letter": { - "anyOf": [ - { - "type": "string", - "maxLength": 4096 - }, - { - "type": "null" - } - ] + "user": { + "$ref": "#/components/schemas/User" } }, "required": [ - "expected_solution_version", - "recipient_user_id" + "registration_id", + "user" ], "additionalProperties": false }, - "UpdateInvitation": { + "SolutionRegistrationRemovalEffect": { "type": "object", "properties": { - "expected_version": { - "type": "integer", - "minimum": 1 + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "expected_solution_version": { + "solution_version": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 9007199254740991 }, - "role": { - "anyOf": [ - { - "type": "string", - "maxLength": 128 - }, - { - "type": "null" - } - ] + "is_submitted": { + "type": "boolean" }, - "specialization": { - "anyOf": [ - { - "type": "string", - "maxLength": 100 - }, - { - "type": "null" - } + "effect": { + "enum": [ + "archive", + "remove_members", + "revoke_invitations" ] }, - "motivational_letter": { - "anyOf": [ - { - "type": "string", - "maxLength": 4096 - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "expected_version", - "expected_solution_version" - ], - "additionalProperties": false, - "anyOf": [ - { - "required": [ - "role" - ] + "removed_members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Member" + } }, - { - "required": [ - "specialization" - ] + "removed_assignments_count": { + "type": "integer", + "minimum": 0 }, - { - "required": [ - "motivational_letter" - ] - } - ] - }, - "InvitationAction": { - "type": "object", - "properties": { - "expected_version": { + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "score_count": { "type": "integer", - "minimum": 1 + "minimum": 0 } }, "required": [ - "expected_version" + "application", + "solution_version", + "is_submitted", + "effect", + "removed_members", + "removed_assignments_count", + "revoked_invitation_count", + "score_count" ], "additionalProperties": false }, - "InvitationCreated": { + "SolutionRegistrationRemovalPreview": { "type": "object", "properties": { - "invitation": { - "$ref": "#/components/schemas/Invitation" + "registrations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionRemovedRegistration" + } }, - "solution_version": { + "effects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalEffect" + } + }, + "deleted_count": { "type": "integer", - "minimum": 1 - } - }, - "required": [ - "invitation", - "solution_version" - ], - "additionalProperties": false - }, - "InvitationActionResult": { - "type": "object", - "properties": { - "invitation": { - "$ref": "#/components/schemas/Invitation" + "minimum": 0 }, - "application": { - "anyOf": [ - { - "$ref": "#/components/schemas/SolutionRef" - }, - { - "type": "null" - } - ] + "archived_count": { + "type": "integer", + "minimum": 0 }, - "solution_version": { - "anyOf": [ - { - "type": "integer", - "minimum": 1 - }, - { - "type": "null" - } + "removed_member_count": { + "type": "integer", + "minimum": 0 + }, + "removed_assignments_count": { + "type": "integer", + "minimum": 0 + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "can_apply": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + }, + "preview_token": { + "type": [ + "string", + "null" ] } }, "required": [ - "invitation", - "application", - "solution_version" + "registrations", + "effects", + "deleted_count", + "archived_count", + "removed_member_count", + "removed_assignments_count", + "revoked_invitation_count", + "score_count", + "can_apply", + "errors", + "preview_token" ], "additionalProperties": false }, - "MutationReceipt": { + "SolutionRegistrationRemovalResult": { "type": "object", "properties": { - "solution_id": { + "application": { + "$ref": "#/components/schemas/SolutionRef" + }, + "solution_version": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "solution_version": { + "change_id": { "type": "integer", - "minimum": 1 + "minimum": 1, + "maximum": 9007199254740991 }, - "updated_at": { - "type": "string", - "format": "date-time" + "action": { + "enum": [ + "archive", + "remove_registrations" + ] } }, "required": [ - "solution_id", + "application", "solution_version", - "updated_at" + "change_id", + "action" ], "additionalProperties": false }, - "SolutionPage": { + "SolutionRegistrationRemovalReceipt": { "type": "object", "properties": { - "count": { + "audit_id": { "type": "integer", - "minimum": 0 - }, - "next": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "minimum": 1, + "maximum": 9007199254740991 }, - "previous": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "deleted_count": { + "type": "integer", + "minimum": 0 }, - "results": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/SolutionSummary" + "$ref": "#/components/schemas/SolutionRegistrationRemovalResult" } } }, "required": [ - "count", - "next", - "previous", - "results" + "audit_id", + "deleted_count", + "items" ], "additionalProperties": false }, - "InvitationPage": { + "SolutionRegistrationRemovalAudit": { "type": "object", "properties": { - "count": { + "id": { "type": "integer", - "minimum": 0 + "minimum": 1, + "maximum": 9007199254740991 }, - "next": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "program_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, - "previous": { + "actor": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/User" }, { "type": "null" } ] }, - "results": { + "actor_name": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "registrations": { "type": "array", "items": { - "$ref": "#/components/schemas/Invitation" + "$ref": "#/components/schemas/SolutionRemovedRegistration" } + }, + "deleted_count": { + "type": "integer", + "minimum": 0 } }, "required": [ - "count", - "next", - "previous", - "results" + "id", + "program_id", + "actor", + "actor_name", + "created_at", + "registrations", + "deleted_count" ], "additionalProperties": false }, - "UploadResponse": { + "SolutionRegistrationRemovalAuditDetail": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri", - "pattern": "^https?://" + "audit": { + "$ref": "#/components/schemas/SolutionRegistrationRemovalAudit" + }, + "changes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionAdminChange" + } } }, "required": [ - "url" + "audit", + "changes" ], "additionalProperties": false }, - "SubmissionSnapshot": { + "SolutionRegistrationRemovalAuditPage": { "type": "object", "additionalProperties": false, "required": [ - "name", - "captain_id", - "members", - "form", - "answers", - "submitted_at" + "count", + "offset", + "limit", + "results" ], "properties": { - "name": { - "type": "string", - "maxLength": 256 + "count": { + "type": "integer", + "minimum": 0 }, - "captain_id": { + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { "type": "integer", "minimum": 1, - "maximum": 9007199254740991 + "maximum": 100 }, - "members": { + "results": { "type": "array", "items": { - "$ref": "#/components/schemas/Member" + "$ref": "#/components/schemas/SolutionRegistrationRemovalAudit" } + } + } + }, + "SolutionRegistrationAdminChange": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 }, - "form": { - "$ref": "#/components/schemas/Form" + "application": { + "$ref": "#/components/schemas/SolutionRef" }, - "answers": { + "action": { + "const": "remove_registrations" + }, + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "type": "null" + } + ] + }, + "actor_name": { + "type": "string", + "maxLength": 512 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "before_version": { + "type": "integer", + "minimum": 1 + }, + "after_version": { + "type": "integer", + "minimum": 2 + }, + "removed_assignments_count": { + "const": 0 + }, + "score_count": { + "type": "integer", + "minimum": 0 + }, + "was_submitted": { + "type": "boolean" + }, + "revoked_invitation_count": { + "type": "integer", + "minimum": 0 + }, + "removed_members": { "type": "array", "items": { - "$ref": "#/components/schemas/Answer" + "$ref": "#/components/schemas/Member" } }, - "submitted_at": { - "type": "string", - "format": "date-time" + "registration_removal_id": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } - } + }, + "required": [ + "id", + "application", + "action", + "actor", + "actor_name", + "created_at", + "before_version", + "after_version", + "removed_assignments_count", + "score_count", + "was_submitted", + "revoked_invitation_count", + "removed_members", + "registration_removal_id" + ], + "additionalProperties": false } } } diff --git a/docs/contracts/program-solutions-v1/validate_contract.py b/docs/contracts/program-solutions-v1/validate_contract.py index 3ed81f22..7a4a68a1 100644 --- a/docs/contracts/program-solutions-v1/validate_contract.py +++ b/docs/contracts/program-solutions-v1/validate_contract.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from jsonschema import Draft202012Validator, FormatChecker +from jsonschema import Draft202012Validator, FormatChecker, ValidationError from openapi_spec_validator import validate_spec ROOT = Path(__file__).resolve().parent @@ -37,13 +37,22 @@ def check(value, schema): if "request" in scenario: content = operation["requestBody"]["content"]["application/json"] check(scenario["request"], content["schema"]) + if "invalid_request" in scenario: + assert scenario["status"] == 400, name + content = operation["requestBody"]["content"]["application/json"] + try: + check(scenario["invalid_request"], content["schema"]) + except ValidationError: + pass + else: + raise AssertionError("Invalid request unexpectedly matches C00: " + name) response = operation["responses"][str(scenario["status"])] if scenario["status"] == 204: assert "response" not in scenario, name else: check(scenario["response"], response["content"]["application/json"]["schema"]) payload = scenario.get("response", {}) - if "team" in payload: + if "team" in payload and "form" in payload and "submission" in payload: team = payload["team"] user_ids = [member["user"]["id"] for member in team["members"]] assert len(user_ids) == len(set(user_ids)) == team["accepted_count"], name @@ -76,12 +85,28 @@ def check(value, schema): assert team["is_frozen"] and payload["form"]["is_snapshot"], name assert payload["submission"]["submitted_at"] is not None, name assert not any(payload["permissions"].values()), name + if payload.get("participation", {}).get("status") == "archived": + assert team["is_frozen"] and payload["form"]["is_snapshot"], name + assert team["pending_count"] == 0, name + assert team["reserved_count"] == team["accepted_count"], name + assert not any(payload["permissions"].values()), name + if payload["readiness"]: + assert not payload["readiness"]["is_ready"], name + assert any(i["code"] == "solution_archived" for i in payload["readiness"]["issues"]), name if payload["readiness"] and payload["readiness"]["is_ready"]: answered = {a["field_id"] for a in payload["answers"]} assert {f["id"] for f in fields.values() if f["required"]} <= answered, name assert ( team["min_size"] is None or team["accepted_count"] >= team["min_size"] ), name + if "published_at" in payload and "fields" in payload: + team = payload["team"] + ids = [row["user"]["id"] for row in team["members"]] + assert len(set(ids)) == len(ids) == team["accepted_count"], name + assert sum(row["is_captain"] for row in team["members"]) == 1, name + fields = {field["id"] for field in payload["fields"]} + answers = [answer["field_id"] for answer in payload["answers"]] + assert len(set(answers)) == len(answers) and set(answers) <= fields, name assert covered == set(operations), ( "Operations missing examples", sorted(set(operations) - covered), diff --git a/docs/product/program-solutions-be02.md b/docs/product/program-solutions-be02.md new file mode 100644 index 00000000..14754519 --- /dev/null +++ b/docs/product/program-solutions-be02.md @@ -0,0 +1,42 @@ +# BE02 — состав команды внутри решения + +Продолжение утвержденных C00 1.0.0 и D00 v3, после BE01/FE01. ТЗ: https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175 . База — dev 562d0727. Новый режим остается выключенным по умолчанию. + +## Контракт и план + +Состав читается в существующем GET решения. Реализуются три уже определенные C00 команды: DELETE `members/{user_id}/` с expected_solution_version в query; POST `leave/` с TeamCommand; POST `captain/` с CaptainCommand. Remove и transfer возвращают MutationReceipt с новой версией; leave — 204. Ни Project ID, ни отдельная Team не появляются. Ответы и ошибки сохраняют snake_case и C00 envelope. Другие команды BE03–BE06 остаются закрыты. + +Все команды проверяют program/solution scope, включенный режим, конкретного капитана или собственное членство, состояние и ожидаемую версию внутри транзакции. Общая блокировка программы сериализует изменения между решениями и старым контуром; затем блокируется решение. Передача допускается только уже принятому участнику и оставляет прежнего капитана в составе. Капитан не удаляется и не выходит до передачи полномочий. Минимум не мешает выходу/удалению в черновике; сданный состав неизменяем. Дедлайн сам по себе не вводит новый запрет управления составом: существующая policy фиксирует состав после сдачи. + +Общая policy регистрации, активной конкурсной exclusivity, максимума с pending резервами и минимума на сдаче применяется через владельца данных. Legacy API и его действующие model/admin проверки учитывают принятые составы новых решений. Завершенные и неконкурсные программы сохраняют прежние исключения; pending во второй команде не означает принятое участие. Новый административный интерфейс решений относится к BE08. + +Проверки в scope: AC04 — вложенный состав; AC06 — полномочия капитана; AC07 — счетчики/границы policy; AC08 — сериализация конфликтующих составов, в том числе с legacy; AC09 — фиксация; AC20 — изоляция программ. Полный цикл приглашений AC05/AC08 и submit AC15 завершаются в BE03/BE06. + +План реализации: общие проверки двух владельцев → сервисы команд под блокировками → C00 HTTP adapters и permissions → PostgreSQL позитивные/негативные/конкурентные тесты и legacy regression → review и PR. + +## Verification и review + +Checked by source: исходная база dev 562d0727. Поддержаны три C00 команды; чтение и объектная область видимости разделяются между API и сервисом без расширения прав организатора. `can_manage_team` доступно только капитану несданного решения в включенном режиме. `can_edit`, `can_invite` и `can_submit` остаются false до следующих этапов. Капитанство передается принятому зарегистрированному участнику; сохранено ограничение одной капитанской заявки программы с контролируемой ошибкой вместо IntegrityError. Удаление и выход могут уменьшать черновик ниже минимума. Решение принадлежит той же программе; входные IDs и версии строго проверяются. Смена режима не скрывает GET, но закрывает команды. + +Checked by tests: **116 tests PASS** на изолированной PostgreSQL 18 / Python 3.11 / settings_ci: + +```text +python manage.py test partner_programs.tests.test_solution_foundation partner_programs.tests.test_solution_team projects.tests.test_competitive_team_policy projects.tests.test_team_security invites.tests.test_invite_integrity invites.tests.test_invite_decisions partner_programs.tests.test_project_apply partner_programs.tests.test_management_application_team_removal --settings=procollab.settings_ci --keepdb --noinput +``` + +Новый `test_solution_team` содержит 21 проверку: права, программа/объект, строгие запросы, версии, C00 receipt, выход и потеря доступа, передача и единственный капитан, freeze, rollback режима, правила сроков, уникальные резервы, минимум/максимум и исторические исключения. Три реальные конкурентные проверки выполняются с разными соединениями: два капитанских действия одной версии, два удаления одной версии и вступление в legacy/solution под общей блокировкой программы. SQLite: **21 tests, OK (skipped=3)**; проверки реальных row locks ожидаемо выполняются только на PostgreSQL. + +Checked by checks: Django check — 0 issues; makemigrations --check --dry-run — no changes; Black и Flake8 измененных Python-файлов PASS; git diff --check PASS. C00 validator PASS: 47 схем, 22 операции, 43 сценария; OpenAPI и примеры не менялись. Отдельного frontend build/browser для backend-only diff нет; текущая FE01 оболочка остается совместимой. Команды FE04 подключаются следующим frontend этапом. + +| AC в scope BE02 | Evidence | Verdict | +| --- | --- | --- | +| AC04: состав принадлежит заявке | GET team, три команды без Project/Team ID | PASS | +| AC06: капитанские полномочия | Член, посторонний, staff и организатор не получают команд капитана; server-side tests | PASS в scope состава | +| AC07: капитан/резервы/границы | Уникальные множества, pending не закрывает минимум, свой резерв не удваивается | PASS для общей policy | +| AC08: exclusivity | Общая policy двух владельцев и PostgreSQL конкурентный admission test; полный accept endpoint — BE03 | PASS в scope policy | +| AC09: фиксация | Remove/leave/transfer отвергаются после submitted; version и состав сохраняются | PASS в scope состава | +| AC20: изоляция | Чужая программа/заявка, безопасные IDs, закрытое чтение после выхода | PASS | + +Review: требования, контракт, финальный diff, error priority, блокировки и прежние admin/model проверки сверены. Минимум не применяется к сохранению/выходу; transfer проверяет принятый состав и общие ограничения, не назначает постороннего. Не осталось открытых findings в scope. Полный цикл принятия приглашений и гонки со сдачей относятся к BE03/BE06 и здесь не объявляются готовыми. Новые команды не имеют административного обхода прав капитана; новый интерфейс администратора остается BE08. + +Миграций нет, режим не включается. Linux CI текущего PR проверяется отдельно перед передачей; локальный отчет не подменяет его. Merge в dev и production deployment не выполняются этим этапом. diff --git a/docs/product/program-solutions-be03.md b/docs/product/program-solutions-be03.md new file mode 100644 index 00000000..c60e6f62 --- /dev/null +++ b/docs/product/program-solutions-be03.md @@ -0,0 +1,23 @@ +# BE03 — приглашения в решение + +Продолжение C00 1.1.0, согласованного D00 v3 и реализации BE01/BE02. ТЗ: https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175 . Команда, принятые участники и приглашения принадлежат ProgramSolution; Project и отдельная Team не создаются. + +## План и контракт + +Реализовать уже определенные C00 операции: список/создание приглашений заявки, входящий список пользователя, безопасное чтение приглашения, PATCH роли/специализации/текста, DELETE pending, accept и decline. Snake_case, строгие поля, пагинация limit/offset/status, существующий Error envelope. Только капитан читает список заявки и отправляет/изменяет/отзывает; только получатель принимает/отклоняет. Безопасное отдельное приглашение доступно капитану и получателю и не раскрывает ответы или чужой состав. Старый /invites/ сохраняется. + +Общие блокировки: программа → решение → приглашение. Все изменения повторно проверяют полномочия, включенный режим и ожидаемые версии. Создание/PATCH/accept запрещены после сдачи; decline/revoke остаются доступны для pending. Проверяются регистрация, принятый состав, активная конкурсная exclusivity в legacy и solution, уникальный pending и максимум с резервами. Accept заменяет свой резерв принятым участником, не удваивая численность. Принятое/отклоненное приглашение не меняется повторно; revoke удаляет pending строку. Решение и получатель не переназначаются. + +Роль nullable по C00; в интерфейсе обязательна нормализованная строка 1–128 с существующими подсказками. Специализация nullable до 100, текст до 4096. Solution.version повышается при любой успешной мутации приглашения; версия приглашения — при PATCH/accept/decline. Accept возвращает ссылку заявки и новую версию решения; decline — null для обоих полей согласно C00. Снимок сданной работы не изменяется от служебных операций. + +Проверки: AC05, AC07–AC09, AC15 в scope приглашений, AC20 и контракты реальных ответов; положительные и отрицательные права, строки/границы/версии, дедупликация, гонки PostgreSQL между решениями и с legacy, read после смены капитана/режима, freeze и сохранение snapshot. Новые правила сроков не вводятся: сохраняется существующая policy команды; дедлайн сдачи применяется отдельной командой BE06. + +## Выполненная проверка + +Реализованы восемь HTTP-операций приглашений и проверки владельца под блокировками. 17 новых тестов включают два конкурентных сценария PostgreSQL. Совместный прогон основы, команды, приглашений и прежних командных сценариев: 104 проверки PostgreSQL прошли. Black, Flake8 и C00 validator (47 схем, 22 операции, 43 примера) прошли. + +Интеграция с Angular на отдельной локальной PostgreSQL: реальный JWT для двух тестовых пользователей, поиск через существующий каталог программы, создание pending, изменение роли, принятие из уведомлений, отзыв, передача капитанства и выход. Проверена мобильная ширина 390 px. Принятие не создает Project и не добавляет участника до согласия. + +Совместимое расширение C00 1.1.0 добавляет необязательный permissions.can_leave: сервер явно разрешает собственный выход принятому участнику; капитану, сданной заявке и выключенному режиму возвращается false. Старые клиенты сохраняют совместимость. + +Полный локальный PostgreSQL-прогон прерван native access violation процесса Python на Windows в прежних сценариях сертификатов. Успешным его не считаем; полный прогон проверяется Linux CI на соответствующем commit. Новый режим остается выключенным по умолчанию; сдача и редактор ответов относятся к следующим BE04–BE06. diff --git a/docs/product/program-solutions-be04.md b/docs/product/program-solutions-be04.md new file mode 100644 index 00000000..f5cde911 --- /dev/null +++ b/docs/product/program-solutions-be04.md @@ -0,0 +1,13 @@ +# BE04: форма решения и стартовые шаблоны + +Реализованы четыре операции C00: шаблоны, чтение формы участником, чтение использования полей организатором и полный PUT конфигурации. Шаблон копируется только явным действием, GET не выбирает его автоматически. Системные название и команда не входят в конструктор. + +PUT под блокировкой программы проверяет полномочия, режим, expected_revision, ID и порядок полей. Типы, назначения, обязательный case/select и ограничения проходят строгую проверку. Порядок нормализуется; новые radio не создаются, существующие читаются. Новый служебный name генерируется сервером; системный case сохраняется независимо от подписи. + +Защищены непустые ответы новой сущности и прежние значения программы. Используемое поле нельзя удалить, перенести, сменить его тип или назначение; использованный вариант нельзя удалить или переименовать. Сужение ограничений проверяется по сохраненным значениям и метаданным UserFile. После ответов нельзя сменить шаблон. Новая обязательность влияет на черновики; affected_draft_count вычисляется сервером, 0 и false считаются ответами. Сданное представление остается в snapshot. + +Миграция 0031 добавляет nullable JSON solution_options для вариантов без потерь, включая символ |. Старые options не переписываются и читаются прежним способом при null. Прежний project-form не записывает определения нового конструктора, включая технический откат режима. Model/admin сохраняют проверки используемых определений; новые команды формы используют единый порядок блокировок программа → поля. В ответ прежнего management добавлен совместимый solution_storage_mode для выбора нужного интерфейса. + +Проверено на локальном PostgreSQL: 70 тестов основы, состава, приглашений и формы; 17 новых сценариев формы включают конкурентные PUT с одной ревизией. Отдельно прошли 130 проверок прежних кейсов/команд/приглашений и 32 проверки управления программой. При первом составлении команды один несуществующий test target дал ошибку импорта; корректный management target проверен отдельно. После ревью моделей повторены 64 проверки и 34 проверки формы/кейсов. Black, Flake8, миграция на локальной БД и контракт C00 прошли. + +Настоящий Angular/JWT сценарий организатора: скопировать КЧ, добавить варианты, сохранить форму и открыть предпросмотр. Проверены серверный счетчик, отсутствие автоселекции кейса и desktop/390 px без JavaScript ошибок и overflow. Предыдущий commit BE02–BE03 55b45c0 прошел Linux PostgreSQL CI (1418 + 39 проверок); это не заменяет CI текущего BE04. Новый режим не включается для пользователей этим изменением. Ответы, файлы и реальная команда сдачи продолжаются BE05/BE06. diff --git a/docs/product/program-solutions-be05.md b/docs/product/program-solutions-be05.md new file mode 100644 index 00000000..8afcba71 --- /dev/null +++ b/docs/product/program-solutions-be05.md @@ -0,0 +1,13 @@ +# BE05: ответы черновика и материалы + +PATCH решения сохраняет частичные ответы капитана с expected_version и expected_form_revision. Пропуск оставляет ответ, null очищает, пустой текст также удаляет запись. Неполная форма допустима; 0 и false сохраняются без преобразования. Чужие и повторные ID, неверные типы, выбор и ограничения отклоняются до записи. Программа, решение, определения и файлы блокируются в общем порядке; конфликт не сохраняет часть запроса. После срока черновик остается доступен для сохранения по прежним правилам, после сдачи и при откате режима запись запрещена. + +Материалы используют существующий UserFile. Проверяются владелец/принадлежность этой заявке, размер до 100 MiB или меньшего предела поля, расширение и тип содержимого. Миграция files 0008 добавляет внутренний detected_mime_type, публичный ответ upload остается ровно {url}. Совместимый C00 1.2.0 добавляет optional query preserve_original=true: материалы сохраняют исходные байты изображения; остальные клиенты по-прежнему получают WebP. Прежняя загрузка без метаданных проверяется только на настроенном CDN без redirects, с таймаутом и ограничением прочитанных байтов. Внешний URL файлом не становится. + +Очистка ответа не удаляет бинарный объект. Ссылка в сданных ответах или immutable snapshot запрещает удаление UserFile через API, admin и ORM до вызова CDN. Admin bulk сначала проверяет всю выборку. Ошибки конструктора BE04 приведены к зафиксированным кодам C00: permission_denied, template_locked и field_in_use. + +Проверено тестами: 90 сценариев решения на локальном PostgreSQL, включая 20 сценариев сохранения/файлов и конкурентные PATCH с одной версией. Проверены настоящий PNG и прежний WebP, подмена MIME, права, размер, очистка, неизменность snapshot, admin bulk и legacy CDN redirect. Black/Flake8, check, makemigrations --check и валидатор C00 прошли. Полный Linux CI проверяется для опубликованного commit отдельно. + +Проверено в браузере: настоящий Angular + Django/PostgreSQL/JWT, обе формы, пустой черновик и явный выбор кейса, сохранение из вкладки команды, конфликт двух вкладок с сохранением ввода, привязка и очистка PDF, байты исходного PNG, повторное открытие 0/false, 390 px без overflow и ошибок JavaScript. Только внешнее хранилище заменено локальным адаптером; HTTP upload, UserFile и PATCH работают реально. + +BE06 остается следующим блоком: атомарная сдача, окончательные проверки готовности и создание снимка. Новый режим для реальных программ не включался. diff --git a/docs/product/program-solutions-be06.md b/docs/product/program-solutions-be06.md new file mode 100644 index 00000000..32d70e94 --- /dev/null +++ b/docs/product/program-solutions-be06.md @@ -0,0 +1,13 @@ +# BE06: сдача решения и неизменяемый снимок + +POST submit принимает обе ожидаемые ревизии и выполняет однократную сдачу под общими блокировками программы, решения, определений, принятого состава и файлов. Повторяются проверки капитана, финального состояния, режима, версий, конкурсности, срока, регистрации/exclusivity состава, минимума/максимума по принятым участникам, кейса, обязательности, типов, ограничений и материалов. После возможной проверки прежнего файла срок повторяется по времени, которое записывается как submitted_at. Включенная граница дедлайна сохранена. + +Успех одной транзакцией пишет status, submitted_at, version и JSON snapshot с названием, капитаном, принятым составом, определениями/ревизией формы и ответами. Pending приглашения не входят в состав снимка. Сданный GET использует снимок; новые подписи, порядок, required и границы программы не делают ранее сданную работу неготовой. Readiness черновика возвращает стабильные ID и причины состава/полей/материалов, can_submit вычисляется из роли и серверного состояния. Неконкурсная программа остается сценарием сохранения без сдачи. + +Все команды API закрывают изменение ответов и состава после сдачи; pending revoke/decline остаются допустимыми. Обычные model saves и collector deletion дополнительно защищают сданные записи и снимок, оставляя версию для служебных действий. Прямые SQL/QuerySet.update используются только как низкоуровневая операция обслуживания и не являются пользовательским способом редактирования. Штатный legacy submit не отправляет отдельного события/уведомления; новая команда не вводит новый эффект и не начисляет оценки. + +Проверено по исходникам: прежние submit_program_project, team_policy, проверка сроков программы и согласованный C00. Проверено тестами: 110 сценариев решения на локальном PostgreSQL, включая 20 новых проверок сдачи. Гонки двух submit, PATCH/submit, accept/submit, remove/submit, формы/submit и удаления файла/submit дают допустимые состояния; повторная сдача, права, срок, пустой кейс, некорректные старые данные, rollback, snapshot и model guards проверены. Black/Flake8 и migration consistency прошли. Полный Linux CI нового commit проверяется отдельно; предшествующий BE05 82e1a81 прошел 1455 + 39 PostgreSQL-проверок. + +Проверено в браузере на настоящих Django/PostgreSQL/JWT: обе формы, предварительный PATCH, ошибки состава/файла с переключением вкладки и фокусом, приглашение и принятие второго участника, отмена подтверждения, mobile 390 px, submit с версией из PATCH, GET сданного состояния, readonly участника и капитана, отзыв pending после сдачи, изменение организатором живой формы без изменения снимка. Upload использовал локальный storage adapter, остальные endpoints и модели реальные. Production и реальные программы не менялись. + +Следующие блоки первого выпуска — BE07–BE09/FE07–FE08, общий QA и включение. BE06 не завершает весь переход на новую сущность. diff --git a/docs/product/program-solutions-be07.md b/docs/product/program-solutions-be07.md new file mode 100644 index 00000000..c8ec412e --- /dev/null +++ b/docs/product/program-solutions-be07.md @@ -0,0 +1,71 @@ +# BE07: оценивание решения — контракт и план + +Статус: API и модели BE07 реализованы; финальная проверка и интеграция общих административных входов продолжаются. Полный BE07/FE07 еще не закрыт. BE02–BE06 завершены отдельными checkpoints. Экспертный UI FE07 ожидает явного согласования D01; backend ведется независимо. + +Current behavior проверен в project_rates/services.py, validators.py, workspace.py, expert_cabinet.py, criteria_policy.py и partner_programs/services/results.py. Критерии общие у программы; int/float имеют конечное значение и min/max, bool хранит True/False, str ограничен 50 символами. В управляемом режиме нужны все нетекстовые критерии; пустой необязательный комментарий сохраняется. Оценка своей команды запрещена в управляемом режиме. Лимит distinct raters применяется к legacy open без required_evaluators; свой уже существующий набор можно обновлять. Распределенный режим требует реального назначения. Приглашение/назначение/подтверждение N явно включает managed workspace. Срок can_rate проверяется текущим кабинетом для всех режимов. + +Target: SolutionScore и SolutionExpertAssignment с настоящими FK на ProgramSolution, прежние Expert, ExpertCaseScope и Criteria. Новые методы используют solution_id; Project/PartnerProgramProject и их ID не создаются. Эксперт читает сданный snapshot и только свои значения оценки. Новые endpoints проверяют срок сервера так же, как доступность действия в кабинете. Публикация результатов и чтение чужих оценок остаются отдельной политикой BE08. Legacy endpoints сохраняют прежний payload. + +Начальный совместимый контракт C00 1.3.0 добавляет: + +| Endpoint | Результат/команда | Права | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| GET /programs/{program_id}/solution-evaluation/ | Пагинация SolutionRatingSummary; поиск, кейс, статус своей оценки, assigned_only | Эксперт своей программы; в distributed только назначенные сданные работы | +| GET /programs/{program_id}/solutions/{solution_id}/evaluation/ | Snapshot Solution, программа, критерии с собственными значениями, criteria_revision, can_rate/причина, срок и счетчики | Видимая работа и эксперт своей программы | +| POST на тот же evaluation/ | expected_criteria_revision + scores[{criterion_id,value}]; обновленный frame | Проверка роли, назначения, состояния, срока, критериев, типа, полноты и лимита под общей блокировкой программы | +| GET/PUT /programs/{program_id}/management/solution-evaluation/experts/{expert_id}/assignments/ | Ревизия и варианты решений / полная выбранная solution_ids | Организатор своей программы; запрет чужой программы, своей команды, несданных работ, недоступного кейса и удаления назначения с оценками | + +Ответы новых методов имеют существующий Error envelope. Неизвестный/чужой объект скрывается 404, видимое запрещенное действие дает 403, устаревшие criteria/assignments — 409. Схемы и независимые примеры добавляются до реализации; старые поля не меняют обязательность. + +План: модели/миграция → shared criteria freeze → чтение/запись оценки и scoped visibility → реальные назначения → безопасное распределение и результаты с прежней математикой → соединение с кабинетом и управлением. Перед добавлением следующих операций их контракт дополняется в том же draft расширении 1.3.0. Открытый/распределенный режим и явный opt-in managed проверяются раздельно. + +Дополнение контракта перед реализацией: + +| Метод | Данные | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| POST management/solution-evaluation/allocation/ | revision, expert_ids, optional solution_ids/case_pools/seed/previous_plan_token/assignment_overrides → подписанный план, дефицит и нагрузка | +| POST management/solution-evaluation/allocation/apply/ | plan_token → program_id, новая revision, число созданных назначений | +| GET management/solution-evaluation/quota/?required_evaluators=N | Последствия изменения N без записи | +| PUT management/solution-evaluation/quota/ | revision и N; сохраненные оценки и избыточные назначения не удаляются | +| GET management/solution-results/ | Группы решений, прежние баллы/места/завершенность; только организатор, private/no-store | + +Реализовано: отдельные FK SolutionScore/SolutionExpertAssignment, unique constraints и индексы; migration 0008. Все записи API сериализованы блокировкой программы; изменение критериев использует ту же блокировку. После первой оценки критерии постоянно зафиксированы, включая обычные model/admin writers. Собственные оценки доступны только эксперту программы в пределах видимости. Новые назначения исключают несданные работы, свою принятую команду и недоступные кейсы; существующие назначения с оценками защищены. Предпросмотр не пишет данные. План действует 15 минут и требует неизменной ревизии; неполный план не применяется. Пересчет использует существующий alternative_pairs, который не читает Project. Общие enable_workspace/revision корректно выбирают ветку решения, сохраняя исторические оценки при явном opt-in. + +Математика вынесена в project_rates/evaluation_math.py и используется прежним results_data проектов и новым solution_results. Сохраняются проверка всех критериев, Decimal/ROUND_HALF_UP, среднее сумм завершенных экспертов, среднее по числовым критериям, отсутствие места у неполной работы, общий ранг до фильтрации и места 1/1/3 при равенстве. Нечисловые критерии участвуют в завершенности, но не добавляют баллы. + +Checked by tests: локальный PostgreSQL regression — 257 выполненных tests, PASS; финальный профильный прогон — 43 PASS (33 новых сценария и 10 существующих tests результатов). Tests проверяют роли, 404 чужих работ, ревизии, NaN/Infinity/диапазон/0/False, full managed и partial open, неизменный snapshot, защиту назначений, квоты, кейсы, подпись/срок плана, reroll и три конкурентных сценария. Отдельный тест создает реальные Project и ProgramSolution в разных программах и подтверждает одинаковые результаты; существующие management results/export tests проходят после извлечения общей математики. Black/Flake8, makemigrations --check и C00 validation PASS. Linux CI на новом commit запускается после публикации; предыдущий BE06 abf23b0 прошел обе CI проверки. + +Checked through running HTTP API: две отдельные локальные программы КЧ и хакатона, JWT эксперта/организатора/участника, сданный snapshot → 0/False → отклонение NaN без порчи оценки → предпросмотр и применение назначения → второй эксперт → итог 0.5 и место 1. Участник получает 403 на административные результаты; эксперт видит только собственные значения. Migration 0008 применена к локальной PostgreSQL 18 на 55439. Реальные программы и внешние письма не использовались. + +Следующий checkpoint BE08 подключает общий кабинет, Django admin и after-commit уведомление. Для c15a995 завершен Linux PostgreSQL CI: 1508 tests и 39 locking tests PASS; общий CI PASS. Осталось до полного продукта: остальные административные входы, публикация материалов по текущим правилам, экспертный UI после D01 и итоговая проверка AC18/20. Аналитика/выгрузки — BE09; новая сущность в целом еще не завершена. + +Requirement audit текущего блока: + +| AC | Evidence | Вердикт | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| AC17 | evaluation frame использует immutable snapshot; профильные API tests и HTTP обеих форм | PASS для API | +| AC18 | Типы, роли, квоты, кейсы, назначения, математика и результаты проверены; публикация и продуктовый UI впереди | PARTIAL | +| AC20 | Scope программы и настоящие FK; private results, чужие Expert/criterion/solution IDs отклоняются | PASS для новых API; общие входы еще впереди | +| AC22 | Existing project rates/workspace и management results/export tests PASS; старые C00 операции и примеры сравнены семантически с HEAD | PASS в проверенном scope | + +Code review: новых блокирующих замечаний в текущем API scope после исправлений не осталось. В review исправлены PostgreSQL IN/DISTINCT ordering, разделение criterion_id/field_id, согласование общей workspace revision и opt-in, защита истории и точных значений кейса. Это не заключение о готовности всего первого выпуска. + +AC17/18/20: snapshot, правильная работа/программа, свои оценки, прежние типы/границы/завершенность, назначение и критерии; AC19 — общий расчет без дублирования Project для последующей аналитики. Проверки включают negative permissions, чужие IDs, 0/False, partial legacy open / full managed, deadline, criteria conflict, atomic rollback, protected assignments, concurrent quota/scores и паритет результатов с legacy. Until checks pass этот документ не является отчетом о готовности. + +## Дополнение 25.10.2026: общий экспертный UI и C00 1.6.0 + +D01 согласован пользователем. Свежие правки общего оценивания из API #823 (c68a004e) и frontend #506 (15905184) интегрированы из dev; старый прототип D01 больше не объявляется источником последних правок. SolutionScore и API принимают комментарий до 1000 символов, как текущий ProjectScore. Migration 0010 объединяет наши 0009 и upstream 0008, 0011 расширяет поле. Проверены model save, прямой service и HTTP: 1000 сохраняется, 1001 отклоняется атомарно без изменения прежних значений. + +RatingPage получил optional evaluation_counts (собственные pending/completed после поиска/кейса/назначений, до статуса/пагинации) и case_options текущей программы. Чужие оценки и закрытая живая форма не открываются. C00 1.6.0: 74 схемы, 34 операции, 59 независимых сценариев; прежние 57 сценариев и операции сохранены, изменения совместимы. Контракт обеих сторон синхронизирован. + +Локальный PostgreSQL regression текущего дополнения: 181 tests PASS; Black/Flake8, makemigrations --check и git diff --check PASS. Миграции применены локально. Реальный HTTP/browser цикл обеих форм подтвердил 0/False/1000 и собственные счетчики; контролируемые HTTP состояния UI не заменяют backend checks прав/сроков. Предыдущий опубликованный checkpoint 47a016a прошел Linux PostgreSQL CI 40135782292: 1533 tests + 39 locking tests; общий CI 40135777711 PASS. Это evidence предыдущего опубликованного commit, не нового дополнения. + +Решение пользователя о публикации: после окончания программы при включенной публикации материалы открываются в разделе «Решения» самой программы; хранение остается в ProgramSolution/ProgramSolutionAnswer, с собственной командой и существующим файловым хранилищем. Project не создается. В неконкурсной программе публикуются сохраненные материалы без искусственной сдачи. Реализация публикации и ее проверки остаются впереди; данные регистрации, приглашения и закрытые оценки не являются публичными материалами. + +## Дополнение C00 1.20.1: архивирование во время оценки + +Если эксперт одновременно организатор, он сохраняет право чтения архивных материалов. Это не разрешает оценивать снятую работу: require_active проверяется в rate_solution до criteria revision/quota/assignment. POST возвращает409/solution_archived вместо ошибочных max_rates_reached/not_assigned. Эксперт без сохраненного доступа по-прежнему получает404. Сохраненные значения, исходная сдача, материалы и состав остаются неизменными; математика и правила публикации не меняются. + +Существующий D01 компонент обрабатывает solution_archived как прекращение оценивания: убирает frame, форму, материалы и предыдущий ввод. Макет/HTML/SCSS не менялись. C00 сохраняет все156 схем/59 операций/297 прежних сценариев и добавляет2 примера (итого299). Новых миграций нет. + +Regression до исправления: два серверных FAIL (quota/assignment) и один component FAIL (старая форма). После:420 native PostgreSQL tests/OK,2949 frontend tests/484 files +strict types/build PASS. Настоящий Chrome/login: новые КЧ47/хакатон48, решения64/65, архив между открытием подтверждения и POST. Обе формы дают409/solution_archived, controls/materials исчезают, retry404;0/False/комментарии и снимки сохранены.36 C00 responses/20 bodies;25 прежних моделей неизменны. Полный AC18 остается OPEN до общего organizer/results/public scope и D02/D03; этот fix его не закрывает. [Отчет с границами и сохраненными неудачными попытками](../qa/program-solutions-archived-rating-20261025/README.md). diff --git a/docs/product/program-solutions-be08-admin-exceptions.md b/docs/product/program-solutions-be08-admin-exceptions.md new file mode 100644 index 00000000..e4021f13 --- /dev/null +++ b/docs/product/program-solutions-be08-admin-exceptions.md @@ -0,0 +1,23 @@ +# BE08: административные исключения после сдачи + +Решение пользователя от 25.10.2026: сохранить административные исключения после сдачи. Обязательное ограничение: существующие программы, где заявками служат проекты, должны продолжать работать; их проекты и связанные данные нельзя потерять или удалить при введении новой сущности. + +Объем первого выпуска включает привилегированные смену кейса, исключение участника и удаление/отвязку заявки по действующим организаторским правам. Обычные действия капитана после сдачи остаются закрыты. Операции организатора требуют явного preview/подтверждения, повторной проверки прав и состояния, учета оценок/назначений и сохранения истории. Смена кейса и хранение истории реализованы в [C00 1.17/BE08A](program-solutions-be08-case-history.md). Исключение участника и его история реализованы в [C00 1.18/BE08B](program-solutions-be08-member-actions.md). Обычные guards действуют; снятие/архив реализованы в [C00 1.19/BE08C1](program-solutions-be08-archive.md), удаление регистрации и аудит реализованы в [C00 1.20/BE08C2](program-solutions-be08-registration-removal.md). + +Legacy_project сохраняет Project/Collaborator/Invite/PartnerProgramProject и прежние маршруты. Новые команды ProgramSolution получают отдельные адреса и проверяют storage mode; числовое совпадение Project/Solution IDs не дает права обработать другую сущность. Обновление не запускает автоматический перенос, удаление Project или замену заявок существующих программ. + +Новая сущность сама владеет материалами, поэтому старое удаление связи нельзя переносить как cascade-delete решения. Реализованное правило: отделить исключение из активного участия от физического хранения исходной сдачи, файлов, команды и оценок, определить обработку назначений/публичного доступа и журнал административных изменений. Сохранение исходных данных является ограничением реализации; История case/member revisions реализована через SolutionAdministrativeChange с before/after snapshots. Снятие с активного участия и сохранение архива реализованы в BE08C1 без физического удаления материалов. Удаление регистрации реализовано в BE08C2 с самостоятельным аудитом и сохранением архивных работ. + +Проверка совместимости выполнена отдельно в [LEGACY acceptance](../qa/program-solutions-legacy-acceptance-20261025/README.md): обычный и старый конкурсный Project прошли команды/приглашения/сохранение/сдачу;22 модели прежних данных совпали до/после. Этот checkpoint не закрывает новые административные writers. D02 approval относится к новому интерфейсу организатора; существующий подтвержденный backend scope можно продолжать независимо. BE10 остается отдельным этапом после первого выпуска и утверждения списка переноса. + +## Оставшийся объем + +| Блок | Статус | +| --- | --- | +| Смена кейса после сдачи и ее история | BE08A реализован/проверен в указанном ACH scope | +| Исключение принятого участника после сдачи | BE08B/C00 1.18 реализован; [контракт, история и границы](program-solutions-be08-member-actions.md), QA evidence отдельно | +| Отвязка/архив заявки | BE08C1/C00 1.19 реализован: материалы/файлы/оценки/состав сохранены, active visibility и новая заявка разделены | +| Удаление регистрации | BE08C2/C00 1.20 реализован: signed preview/apply, аудит без решения, роли/архив/материалы сохранены | +| История остальных административных действий | Case/member/archive/registration реализованы; новый organizer UI остается OPEN | +| Старые программы/Project | Сохранены; при следующих writers повторить regression/данные, автоматического переноса нет | +| Организаторский UI и полный QA01/REL01 | OPEN, D02 approval уже запрошен | diff --git a/docs/product/program-solutions-be08-archive.md b/docs/product/program-solutions-be08-archive.md new file mode 100644 index 00000000..95761dcf --- /dev/null +++ b/docs/product/program-solutions-be08-archive.md @@ -0,0 +1,41 @@ +# BE08C1: снятие заявки с участия и сохранение работы + +Пользователь согласовал административные исключения после сдачи с обязательным сохранением старых Project-программ и данных. Этот блок переносит действующее detach-поведение на ProgramSolution. Удаление регистрации остается отдельным BE08C2 и не подменяется архивированием. + +## Модель и доступ + +Сдача и участие независимы: status остается draft/submitted; archived_at/archived_by/archive_reason/archive_snapshot фиксируют снятие с участия. Обычные writers не устанавливают и не сбрасывают архивные поля. После архивирования model save/delete и обычные команды состава/материалов закрыты. Captain FK, принятый состав, ответы и UserFile остаются у той же сущности. + +ProgramSolution.objects и обратная program.solutions возвращают активные работы. all_objects и base manager включают архив для сохранения связей и явных исторических чтений. Уникальность captain/program действует для активной заявки. Текущая заявка, exclusivity, предложения участников, оценивание, аналитика, результаты, назначения/распределение, напоминания и публикация используют активное участие. Historical score opt-in не восстанавливает назначения архивной работы. + +Капитан и сохраненный состав читают собственный архив по прежнему solution URL; organizer/admin своей программы читает архив и историю. Экспертное назначение или сохраненная оценка не дает доступ к архивной работе. GET solutions по умолчанию participation=active; archived/all открывает собственные архивы и все архивы организатору. Optional participation в Solution/Summary сообщает состояние/дату/причину. Все permissions архивной работы false, team.is_frozen=true. Регистрация и возможность новой заявки проверяются независимо по прежним правилам срока/формы/creation flag. + +## Подтвержденное снятие + +POST `/programs/{program_id}/management/solutions/archive/preview/` принимает ids1–10000 без повторов; apply дополнительно требует точный preview_token. IDs — безопасные положительные целые; bool/string/fractional/unsafe, неизвестные body/query и пустой список запрещены. Действие доступно organizer/admin своей программы и solution mode. Legacy Project handlers и данные сохраняются. + +Preview read-only показывает SolutionRef/version/status, количество оценок, отзываемых pending и снимаемых назначений. Архивная работа отмечена can_apply=False и null token. Signed proof действует10 минут, связан с actor/program/ids и fingerprint материалов/версий/состава/приглашений/файлов/оценок/назначений. Apply повторяет проверку прав и состояния под program → solutions → fields/members/invitations/UserFile locks. Срок, текущая форма и creation-only flag не запрещают административное снятие. + +Операция атомарна для всей выборки. Все pending выбранных работ отзываются; обработанные приглашения остаются историей. Снимаются все назначения, включая оцененные, как при прежнем detach; оценки сохраняются. Привилегированный writer вызывает базовое ORM удаление только для подтвержденных назначений; обычная score protection остается. Каждая работа получает version+1 и собственное событие archive. Ошибка журнала откатывает архив, приглашения, назначения и версии всей транзакции. Повтор/stale/expired/чужой proof дает409 без повторной ревизии. + +## Сохранение материалов + +Archive snapshot содержит name/captain_id/members/form/answers/captured_at. Form помечена is_snapshot=true. Черновик остается черновиком с submitted_at=null и submission_snapshot=null; архивирование не имитирует сдачу. Для сданной работы исходный submission_snapshot и время сдачи не меняются. Читатели архива используют archive_snapshot, поэтому позднейшая настройка формы не меняет сохраненную работу. + +Файлы архивного черновика защищены от удаления до CDN-вызова и через ORM/admin, как файлы сданной работы. Проверка использует unfiltered сохраненные snapshots и ответы; фильтр активных решений не ослабляет retention. Публикационный marker сохраняется, но архив отсутствует в публичном разделе программы и не публикуется повторно. Это не меняет согласованный неконкурсный publication scope; вопрос конкурсных несданных черновиков остается открытым. + +SolutionAdministrativeChange поддерживает archive наряду с change_case/remove_member. Сохраняются actor/name/version/effects, unchanged before/after submission snapshots либо оба null для draft, отдельный archive_snapshot и причина. History detail доступен organizer/admin и для архива; старые case/member payloads не меняются. Public API текущего снятия устанавливает reason=detached; registration_removed зарезервирован для отдельной команды удаления регистрации. + +## Контракт и реализация + +C00 1.19.0:145 схем/55 операций/272 независимых сценария;250 прежних сценариев сохранены. Добавлены9 схем и2 операции. Четыре прежние схемы расширены optional participation/archive snapshot и archive ветвью history; остальные132 прежние схемы сохранены. Единственная измененная прежняя операция listSolutions получила participation filter;52 прежние операции неизменны. Shared OpenAPI/examples/README byte-identical с frontend. + +0037 добавляет архивные поля/constraints и условную уникальность активного капитана, сохраняя прежние rows и4 события case/member. Миграция не архивирует, не переносит и не удаляет существующие работы или Project. Старые программы сохраняют storage mode и маршруты. Только новые локальные synthetic программы используются для rehearsal. + +Frontend API layer содержит generated metadata/snapshot/union, bulk preview/apply port/repository/adapter/use cases и participation query. Нового organizer layout/routes/SCSS и навигации по архиву не добавлялось; соответствующий UI остается в первом выпуске и следует уже запрошенному D02/design approval. D03 и остальные исходные gates сохраняются. + +## Проверки и выпуск + +20 native PostgreSQL tests включают3 observed-lock races archive×archive/submit/score, draft/submitted/noncompetitive, bulk atomicity, scoped rights/proof, read-only retention, новую заявку той же команды, active selectors, historical assignments, public marker и file protection до CDN. Они входят в общий solution suite. Архивные snapshot и история проверены настоящим HTTP; [итоговый отчет](../qa/program-solutions-archive-20261025/README.md) содержит refs/логи/ограничения. + +0037 применяется до запуска нового backend; backend/миграция предшествуют frontend. После первого archive event backend и фоновые workers должны сохранять правила C00 1.19: старый backend не понимает архив и не является допустимой версией отката. При остановке пилота creation-only flag выключает новое создание; архивные поля/снимки/историю не откатывать вниз, совместимый reader и writer guards сохранять. Флаг не выключает административные команды. Полный rollout/rollback rehearsal остается в REL01; merge/deploy/реальные переключения и BE10 этим этапом не выполняются. diff --git a/docs/product/program-solutions-be08-case-actions.md b/docs/product/program-solutions-be08-case-actions.md new file mode 100644 index 00000000..d26e95c3 --- /dev/null +++ b/docs/product/program-solutions-be08-case-actions.md @@ -0,0 +1,38 @@ +# BE08: подтвержденная смена кейса + +Текущий этап C00 1.17 реализует также смену кейса после сдачи с сохранением исходного снимка и истории: [модель, контракт и приемка BE08A](program-solutions-be08-case-history.md). Сохраняются оценки и оцененные назначения, обычные participant writers остаются frozen. Удаление/отвязка/исключение участников еще предстоят. + +## История этапа C00 1.15/1.16 + +До реализации проверены legacy management_project_actions.inspect_action/apply_action, validate_case_value и serializer. Смена кейса относится к одной заявке, доступна organizer/admin своей программы, требует точного актуального варианта и signed preview/confirm. Материалы проекта и оценки не переписываются этим действием; несовместимые неоцененные назначения снимаются отдельно в старой ветке. + +Реализованная C001.15/1.16 ветка переносит смену кейса несданного ProgramSolution; текущий submitted guard пока действует. Пользователь позже согласовал административные исключения после сдачи с обязательным сохранением старых Project программ. [Расширение BE08](program-solutions-be08-admin-exceptions.md) еще нужно реализовать; прежний draft-only guard не является окончательной политикой. Удаление/отвязка новой сущности не реализованы: legacy сохранял исходный Project, а решение само владеет материалами, поэтому cascade delete не подменяет безопасное хранение. Нового Angular UI нет; он зависит от D02 approval. + +## Контракт до реализации + +C00 1.15.0 добавляет два POST `/programs/{program_id}/management/solutions/{solution_id}/case/preview/` и `/case/apply/`. В request точный case_name (1–1000 символов, не только пробелы); apply дополнительно preview_token. Unknown fields/query и unsafe IDs отклоняются. Старый submit action API и все прежние 125 schemas/47 operations/185 scenarios сохраняются без изменения. + +Preview возвращает настоящий SolutionRef, case_field_id, current_case (nullable), целевой case_name, solution_version, Issue[], can_apply и nullable token. Проверяются mode/manager scope/текущая форма/вариант. Тот же кейс, submitted или неожиданное наличие оценок/назначений дают can_apply=false с объяснением и token=null. Preview не пишет domain data. Неконкурсный draft поддерживается: выбор кейса не требует искусственной сдачи. Creation flag/deadline не отключают действие над существующей заявкой. + +Apply привязан подписью на 10 минут к actor/program/solution/целевому кейсу и сохраненному состоянию. Program→solution→fields/answers/members locks и повторная проверка защищают от lost update. Fingerprint включает текущую форму, версию/капитана/название/status/snapshot/published_at, ответы/состав и наличие оценок/назначений. Чужой/истекший/устаревший token и replay дают409 solution_action_conflict; submitted сохраняет409 team_frozen. Изменяется только case answer и версия/updated_at решения; остальные ответы, участники, snapshot, оценки и назначения не удаляются. Непредвиденные evaluation records у draft блокируют команду. Ответ — существующий MutationReceipt. + +400 — invalid payload/case, 401 anonymous, 403 manager scope, 404 foreign/missing solution, 409 mode/unconfigured form/token/state/frozen. Все ответы private/no-store. Новых моделей/миграций нет. Прежние legacy handlers не меняются. + +## Приемка + +| AC | Ожидаемый результат | +| --- | --- | +| CASE01 | Organizer/admin только своей программы; participant/expert/foreign IDs/legacy/unsafe input закрыты, private/no-store | +| CASE02 | Preview read-only, правильные SolutionRef/current/target/version/field; null token для same/submitted/unexpected evaluation | +| CASE03 | Exact options включая пробелы/символ pipe, смена null→case и case→case; только case answer/version/time изменены, 0/false и состав сохранены | +| CASE04 | Token actor/program/solution/case/expiry/signature/state и replay проверяются; изменение формы/ответов/состава/status не теряется | +| CASE05 | Submitted snapshot/score/assignments не меняются; normal captain deadline/rights и прежние action paths сохранены | +| CASE06 | Два confirm, confirm×captain PATCH и confirm×submit реально ждут program lock; один допустимый исход без lost update/двойной версии/изменения frozen snapshot | +| CASE07 | C00 additive/independent examples, generated frontend types/port/repository/adapter/use cases и реальные interceptor wire tests согласованы | +| CASE08 | PostgreSQL regression, настоящий HTTP КЧ/хакатона/noncompetitive, audit существующих данных и source review подтверждают scope | + +Порядок: spec/C00 → backend/service/API → PostgreSQL verification → frontend API layer → real HTTP → review → draft PR. Полный QA01/REL01 и BE10 этим блоком не закрываются. + +## Результат + +CASE01–CASE08 PASS в API scope; [evidence, точные refs/hashes и границы](../qa/program-solutions-case-actions-20261025/README.md). C001.15 содержит128/49/209 при неизменных прежних125/47/185. Read-only preview, signed state и single-field write реализованы отдельным service/API; старый submit action не расширялся.19 новых PostgreSQL tests включают три наблюдаемых program-lock races и настоящие frozen score/assignment records. Broad356 PASS до последнего token-type guard; final52 после него и добавления submit race PASS. Actual115 C00 responses/88 valid JSON requests и15 rejected invalid requests трех форматов, no domain writes verifier; frontend2827/480+strict types/build/lint PASS. Full feature/manager UI/post-submission policy и CI нового commit отдельно не объявляются завершенными. diff --git a/docs/product/program-solutions-be08-case-history.md b/docs/product/program-solutions-be08-case-history.md new file mode 100644 index 00000000..8123ab63 --- /dev/null +++ b/docs/product/program-solutions-be08-case-history.md @@ -0,0 +1,38 @@ +# BE08A: смена кейса после сдачи и история + +Пользователь согласовал административные исключения после сдачи с обязательным сохранением старых программ и проектов. Этот этап реализует смену кейса и хранение ее истории. Исключение участников, удаление регистрации, отвязка/архив заявки остаются следующим объемом [BE08](program-solutions-be08-admin-exceptions.md); новый интерфейс организатора зависит от D02 approval. + +## Модель хранения + +ProgramSolution продолжает владеть материалами, составом и текущим submission_snapshot. Явная привилегированная смена кейса обновляет кейс в текущем снимке и соответствующем ответе. Другие материалы, состав, капитан, status, submitted_at и оценки сохраняются. Обычные participant/model writers остаются frozen. + +SolutionAdministrativeChange хранит actor/actor_name, before_version/after_version, точные старый/новый кейсы и case_field_id, IDs снятых назначений, число сохраненных оценок и полные before_snapshot/after_snapshot. До первой административной правки before_snapshot сохраняет исходную сдачу; следующие записи сохраняют последующие версии. У черновика оба snapshot=null: отдельная сдача не создается. Каждый подтвержденный case action увеличивает version на1 и создает одну запись истории в той же транзакции. + +Миграция0035 создает только таблицу истории и ограничения уникальности/версий/парности снимков. Она не переносит Project, не переписывает старые решения и не меняет режим программ. История доступна для чтения, добавление/изменение/удаление через Django admin закрыты; model save существующей записи и удаление защищены. Связь с решением PROTECT сохраняет историю при попытке физического удаления. Архивирование вместо удаления — отдельный еще не реализованный этап. Бинарные файлы не копируются: снимки сохраняют прежние ссылки UserFile/CDN. + +## C00 1.17 + +Прежние POST case/preview и case/apply имеют прежние request bodies. Preview дополнен is_submitted, removed_assignments_count и score_count. Для корректного сданного решения can_apply=True; подписанное подтверждение привязано на10 минут к actor/program/solution/точному case_name и состоянию. Тот же кейс не применяется повторно. Неожиданные оценки/назначения у draft по-прежнему блокируют команду. + +Под program→solution→fields/answers/members locks повторяются права, storage mode, текущий вариант и fingerprint. Fingerprint включает snapshot/version/answers/members, полные значения оценок, назначения и case scopes. Изменение оценки без смены ее ID, изменение scope, конкурирующая сдача или другая правка делает прежнее подтверждение устаревшим. Replay и stale token дают409/solution_action_conflict. Ошибка записи истории откатывает ответ, snapshot, version и снятие назначений целиком. + +Существующее правило management_project_actions перенесено без удаления оценок: снимается только несовместимое назначение эксперта, у которого нет сохраненных оценок решения. Оцененные назначения сохраняются. case_field_id относится к применяемому снимку; подписи и порядок прежней формы не заменяются текущими настройками при обычной смене кейса. Срок сдачи и creation-only flag не отключают это действие над существующим решением. + +GET `/programs/{program_id}/management/solutions/{solution_id}/history/` возвращает count/offset/limit/results, новые записи первыми; limit1–100, default20, offset default0. GET `.../history/{change_id}/` возвращает change и before_snapshot/after_snapshot существующего SubmissionSnapshot DTO. История ограничена парой program/solution/change, доступна только organizer/admin своей программы и не требует актуальной настроенной формы. Неизвестные query запрещены; все ответы private/no-store. В ответах нет регистрационных данных или email. Обычные участники и эксперты не получают историю. + +Добавлены3 схемы и2 операции: итог131 схем/51 операция/230 независимых сценариев. Из старых128 схем расширена только SolutionCaseChangePreview;127 прежние. В6 прежних preview примерах добавлены эффекты, а два submitted примера намеренно меняют запрет на согласованное разрешение; всего изменены7 прежних сценариев,210 прежних неизменны,13 новых. Legacy project endpoints и модели не менялись. Backend/миграция → frontend; фронтовые port/repository/adapter/use cases и generated types согласованы. + +## Приемка этого этапа + +| ID | Результат | +| --- | --- | +| ACH01 | Draft и submitted case preview/apply сохраняют точный ввод, scopes и права; обычные writers остаются frozen | +| ACH02 | Все прежние материалы/0/false/состав/дата сдачи и оценки сохранены; только несовместимое неоцененное назначение снято | +| ACH03 | Исходный и последующий snapshots доступны в приватной истории; список/пагинация/foreign IDs/unknown query и запрет записи проверены | +| ACH04 | Score value/scope changes, stale token/replay и два concurrent confirm не перезаписывают данные; сбой истории дает полный rollback | +| ACH05 | Confirm×score update использует общий program lock и сохраняет выигравшее состояние/оценку | +| ACH06 | Миграция и новые действия не переписывают прежние22 выбранные доменные модели, не создают и не удаляют Project | +| ACH07 | КЧ и хакатон проходят настоящий HTTP с закрытым сроком/выключенным созданием, legacy context сохранен, solution endpoints в legacy mode закрыты | +| ACH08 | C00/types/wire/native PostgreSQL/frontend regression/build/static checks согласованы; это не полный BE08/QA01/REL01 | + +Проверки и точные runtime/source refs сохраняются в [отчете ACH](../qa/program-solutions-case-revision-20261025/README.md). Остальные административные действия, D02/D03, конкурсная публикация несданных черновиков и полный выпуск остаются открытыми. diff --git a/docs/product/program-solutions-be08-member-actions.md b/docs/product/program-solutions-be08-member-actions.md new file mode 100644 index 00000000..98765033 --- /dev/null +++ b/docs/product/program-solutions-be08-member-actions.md @@ -0,0 +1,35 @@ +# BE08B: исключение участника решения организатором + +Решение пользователя от 25.10.2026 сохраняет административные исключения после сдачи и требует совместимости действующих Project-программ. C00 1.18.0 фиксирует отдельное исключение принятого участника. Удаление регистрации, снятие/архив заявки и капитан в рамках этих действий остаются следующим объемом BE08. + +## Поведение и границы + +Организатор своей программы и администратор получают POST `/programs/{program_id}/management/solutions/{solution_id}/members/remove/preview/` и `apply/`. Тело preview содержит `user_id`, подтверждение дополнительно требует `preview_token`. Неизвестные поля/query, bool/string/fractional/unsafe IDs и отсутствующий token отклоняются. Ответы и ошибки имеют `Cache-Control: private, no-store`. + +Preview показывает выбранного Member, оставшийся состав, состояние сдачи, число отзываемых pending-приглашений и сохраненных оценок. Он не меняет данные. Капитан показан с `can_apply=False`, ошибкой `captain_cannot_be_removed` и null token; операция не оставляет заявку без капитана. Отсутствующий участник дает404. Капитан, участник, эксперт и посторонний организатор не получают привилегию. Legacy mode дает409 и сохраняет прежние Project endpoints. + +Signed proof действует10 минут и связан с actor/program/solution/user. Apply заново проверяет права и состояние после блокировок program → solution → members → invitations. Fingerprint включает решение/snapshot/version, роли/состав, приглашения, ответы, назначения и полные значения оценок. Измененный или использованный proof дает409/solution_action_conflict без повторного удаления и второй ревизии. Срок сдачи, текущая настройка формы и creation-only flag не закрывают утвержденную административную операцию. + +Подтверждение удаляет членство только выбранного пользователя в выбранном решении и отзывает его pending-приглашения. Регистрация программы остается. Обработанные accepted/declined приглашения сохраняются для истории; сами по себе они не дают доступ к материалам и не могут быть приняты повторно. Другие заявки и команды пользователя не меняются. После исключения участник перестает получать закрытые материалы через членство; независимые manager/expert/public права проверяются обычными правилами. + +Материалы, ответы/UserFile, капитан, статус и время сдачи, оценки и назначения не меняются. Для сданного решения текущий snapshot меняет только `members`. Исключение может уменьшить состав ниже min_size, как прежнее административное действие; min_size продолжает проверяться при обычной первой сдаче. Неконкурсный черновик остается черновиком без искусственной сдачи. + +Удаление членства использует обычный ORM delete и строго ограниченную внутреннюю ContextVar capability для одной строки `(solution_id, member_id, user_id)`. Она существует только на время подтвержденного удаления и сбрасывается в finally, в том числе при ошибке. Обычные model/queryset/admin/cascade удаления сданного состава по-прежнему защищены pre_delete. Смена snapshot выполняется отдельным привилегированным writer; обычное редактирование после сдачи остается закрыто. + +## История и хранение + +SolutionAdministrativeChange дополнен nullable case fields, `removed_member` и `revoked_invitation_ids`; constraint различает change_case/remove_member. Миграция0036 меняет только таблицу истории, не переносит Project и не переписывает существующие сдачи. Прежние записи смены кейса сохраняются. + +Удаление членства, отзыв pending, новый snapshot/version и запись истории выполняются в одной транзакции. Ошибка журнала откатывает все изменения. История хранит исключенного Member и снимки до/после; для draft оба snapshot null, Member остается в событии. Новая ревизия увеличивает solution version ровно на1. История остается неизменяемой и доступна только организатору/администратору своей программы через существующие history list/detail; список поддерживает оба типа событий. Старая форма ответа change_case сохранена без дополнительных полей. + +C00 1.18:136 схем,53 операции,250 независимых сценариев. Все230 прежних сценариев и51 прежняя операция сохранены. SolutionAdminChange — discriminated union по action; SolutionCaseAdminChange в точности сохраняет прежнюю схему case event. Остальные130 прежних схем не меняются. Негативные `invalid_request` проверяются как не соответствующие request schema; валидатор не ослабляет контракт ради400-примеров. + +## Проверки + +18 новых native PostgreSQL tests проверяют draft/submitted/noncompetitive, права/режим/ID/строгое тело/query, сохранность материалов/регистраций/оценок/назначений, потерю доступа исключенного, историю, rollback и frozen guards. Три observed-lock races проверяют confirm × confirm, confirm × submit и confirm × score; helper доказывает ожидание обоих PostgreSQL backend на блокировке программы. Эти18 входят в общий solution suite, не прибавляются к нему повторно. + +Frontend API layer содержит generated union/DTO, port/repository/adapter и два use case;22 новых wire checks проверяют body/token/effects/errors/unsafe IDs через настоящий interceptor. Еще3 history wire checks покрывают member detail/draft и смешанный список. Новый organizer layout/routes/SCSS не добавлялся: D02 approval уже запрошен. Результаты полного регресса и настоящего локального HTTP зафиксированы в отдельном QA отчете. + +## Откат + +0036 применяется после0035 до нового backend/frontend. При остановке пилота сохраняются таблица истории, поля и записи; schema-down удалил бы доказательства административных изменений. Читатель истории должен понимать оба action. Creation-only flag ограничивает создание и не является выключателем административных команд; откат запуска не подменяется переключением storage mode. Полный rehearsal первого выпуска остается в REL01. Старые программы продолжают использовать Project; BE10 выполняется отдельно после выпуска и утверждения списка переноса. diff --git a/docs/product/program-solutions-be08-registration-removal.md b/docs/product/program-solutions-be08-registration-removal.md new file mode 100644 index 00000000..2a6ded98 --- /dev/null +++ b/docs/product/program-solutions-be08-registration-removal.md @@ -0,0 +1,22 @@ +# BE08C2: удаление регистрации с сохранением работы + +Основание: согласованные пользователем административные исключения после сдачи и обязательная сохранность существующих Project-программ. C00 1.20.0 добавляет отдельные команды решений. Старые обработчики удаления участников/регистраций не изменяются. + +Организатор/admin своей программы отправляет `registration_ids` — ID PartnerProgramUserProfile, а не User или решения — в `POST /programs/{program_id}/management/registrations/remove/preview/`. Выборка содержит 1–10000 уникальных безопасных целых ID; чужие или отсутствующие регистрации дают 404. Preview показывает выбранных людей и эффекты по каждой активной работе. Применение по `/apply/` требует точный подписанный token, привязанный к организатору, программе, выборке, текущим регистрациям/людям/работам/файлам/составу/приглашениям/оценкам/назначениям. Срок подтверждения 10 минут. Изменение состояния, повтор, подмена или истечение подтверждения дают 409; права проверяются заново. Неизвестные body/query запрещены. Ответы private, no-store. + +В одной транзакции с блокировками program → выбранные profiles → решения по ID выполняются следующие эффекты: + +- Капитан выбранной активной заявки: архив reason=`registration_removed` через общий writer BE08C1. Материалы, UserFile, исходная сдача, состав и оценки сохраняются. Pending-приглашения и все назначения снимаются; оцененные назначения также снимаются, оценки остаются. Черновик получает archive_snapshot без искусственной сдачи. +- Принятый участник: удаляется из текущего состава и текущего сданного снимка; исходный состав и новая ревизия записываются в историю. Материалы, оценки, назначения и обработанные приглашения сохраняются. Связанные pending отзываются. +- Получатель только pending-приглашения: pending отзывается, состав и снимок материалов не меняются; появляется ревизия и история. Историческое принятое приглашение само по себе не дает доступ к материалам. +- Регистрация без активной работы: удаляется с самостоятельной записью аудита. Архивные работы, их состав, снимки и прежняя история не меняются. + +Несколько выбранных участников одной работы дают одну ревизию. Если выбран ее капитан, применяется один архивный эффект с сохранением состава. Затем удаляются только выбранные регистрации этой программы. Auth User, роли manager/expert, экспертные оценки и другие программы не удаляются. Закрытый прием/выключенный creation flag не блокируют административное исключение. Новая заявка капитана возможна только после регистрации и при соблюдении обычных условий программы. + +Если профиль содержит Project или выбранный человек связан с Project-заявкой этой программы как руководитель, участник или получатель приглашения, вся команда получает `legacy_application_conflict` без изменений. Она не пытается автоматически исправлять смешанные данные. В legacy_project режиме новый адрес возвращает `solution_mode_unavailable`; действующие маршруты продолжают работать. + +Неизменяемый SolutionRegistrationRemoval хранит программу, организатора, время и минимальные пары registration_id/User без копии анкеты и email. Он существует независимо от решения. Список `/management/registrations/removals/` поддерживает offset/limit; detail `/{audit_id}/` возвращает аудит и связанные изменения. Доступ только у организатора/admin своей программы. SolutionAdministrativeChange получает ветку `remove_registrations` с removed_members и registration_removal_id; архивный эффект содержит optional registration_removal_id. Старые case/member/archive payloads сохраняются. + +Миграция 0038 добавляет аудит и nullable-связи истории, без удаления/архивации/переноса существующих записей. Она применяется до backend, затем frontend. После первого C2 события readers/backend/workers сохраняют поддержку C00 1.20; поля и аудит не откатываются вниз. Creation-only rollback продолжает выключать только создание. Для полного REL01 все еще нужна приемка первого выпуска. + +Реализован frontend port/adapter/repository и четыре use cases с общим C00. Новый organizer UI/подтверждение/журнал и навигация архива следуют согласованию D02; D03 и конкурсная eligibility несданных черновиков остаются открыты. Этот backend/API checkpoint не заменяет эти экраны или исходный QA01. Реальные программы не переключались; merge/deploy/BE10 не выполнялись. diff --git a/docs/product/program-solutions-be08-submit-actions.md b/docs/product/program-solutions-be08-submit-actions.md new file mode 100644 index 00000000..0fa52e4d --- /dev/null +++ b/docs/product/program-solutions-be08-submit-actions.md @@ -0,0 +1,55 @@ +# BE08: первая сдача организатором + +Source: services/management_project_actions.py, submission_errors/inspect_action/apply_action и test_management_project_actions.test_submit_after_deadline_validates_and_preserves_old_leader_contract. Действующий submit организатора проверяет материалы и состав, допускает позднюю первую сдачу, требует signed preview и повторной проверки под блокировками. can_apply требует хотя бы одну готовую несданную работу и отсутствие ошибок всей выборки; уже сданные работы учитываются отдельно, их дата не меняется. + +Этот реализованный блок переносит только первую сдачу на ProgramSolution и не перезаписывает уже сданную работу. Смена кейса после сдачи с историей реализована отдельно в C00 1.17/BE08A. Исключение участников после сдачи реализовано отдельно в C00 1.18/BE08B. Отвязка/архив с retention реализованы отдельно в C00 1.19/BE08C1. Удаление регистрации остается следующим объемом [BE08](program-solutions-be08-admin-exceptions.md) с обязательным сохранением старых Project программ. Новый Angular UI зависит от D02 approval и в scope этого блока не входит. + +## Контракт до реализации + +C00 1.14.0: POST /programs/{program_id}/management/solutions/actions/preview/ и /apply/. Request: action="submit", ids (1–10000 безопасных JS IDs, сервер сортирует и удаляет повторы); apply дополнительно preview_token. Неизвестные поля/query, unsafe/bool IDs и неподдерживаемое действие отклоняются. Отдельные solution paths исключают смешение с legacy program-project link IDs; прежний projects/actions контракт не меняется. + +Preview: items с настоящими SolutionRef и Issue[], ready_count/already_submitted_count/can_apply/nullable preview_token. Только organizer/admin конкретной программы, solution mode и competitive. GET/POST preview не меняет domain data. Нет обхода обязательных полей, case/team policy и файла. Срок не делает готовое решение неприменимым для существующего привилегированного действия. + +Apply: токен подписан на 10 минут и привязан к actor/program/action/нормализованным IDs и fingerprint сохраненного состояния. Под блокировками program → solution → fields/members/files повторяются права, mode, competitive, token/state и все проверки первой сдачи. Измененная или неготовая выборка дает 409/solution_action_conflict; недоступный файл при окончательной проверке дает обычную C00 ошибку без частичной сдачи. Вся выборка атомарна. Обычный captain submit сохраняет deadline и final recheck после I/O; manager не получает HTTP force flag. Уже сданные работы не перезаписываются. Result: changed_count/already_submitted_count. + +Responses: 200 success; 400 validation/материалы; 401 anonymous; 403 чужая роль/организатор; 404 unknown/чужой solution; 409 legacy/noncompetitive/устаревший или чужой token/state. Все ответы private, no-store. Новых моделей/миграций нет, creation switch не препятствует работе с существующей заявкой. + +## Приемка + +| AC | Проверка | +| --- | --- | +| SADM01 | Только organizer/admin своей программы; остальные роли/foreign IDs/legacy/noncompetitive и invalid input закрыты C00 ответом | +| SADM02 | Preview не пишет domain data, показывает точные per-solution issues и counts; все already submitted дают can_apply=false/token=null | +| SADM03 | Captain после срока получает submission_closed; organizer по signed confirmation впервые сдает готовую работу с accepted-only snapshot, 0/False и настоящей формой | +| SADM04 | Пустые required/case, неверный case, состав/регистрация/лимиты и файл не обходятся привилегией | +| SADM05 | Token actor/program/action/IDs/expiry/signature и fingerprint name/answers/team/form/policy/files проверяются; stale/replay не пишет | +| SADM06 | Bulk atomic, mixed submitted сохраняет прежнюю дату/snapshot, изменяется только готовый draft; частичная ошибка не сдает соседнее решение | +| SADM07 | Два concurrent confirm дают один успех, изменения материалов/команды/формы сериализованы program row lock; уже сданный snapshot неизменен | +| SADM08 | Обычный submit сохраняет permission, revision, deadline до/после I/O и прежние regression; legacy actions не меняются | +| SADM09 | C00 прежние 120 schemas/45 operations/161 scenarios сохранены; обе копии/генерация/API layer и strict wire tests согласованы | +| SADM10 | Реальный HTTP/JWT и final review подтверждают scope; нет Project/Team, переключения реальных программ, merge/deploy или реализации D02 UI | + +Порядок: C00/spec → общий finalize первой сдачи → signed preview/apply и API → PostgreSQL tests → frontend API layer → реальный HTTP → review/fixes → draft PR. Это часть BE08/FE08, не готовность всего первого выпуска. + +## Результат и requirement audit + +Реализован отдельный API, прежние project actions не менялись. Общий `_finalize_submission` сохраняет прежние проверки и snapshot для капитана; поздняя ветка вызывается только из подтвержденной организаторской команды. Preview/apply используют program/solution/field/member/file locks. Review дополнительно перенес захват UserFile locks до fingerprint, поскольку метаданные файла могут иметь writer вне команд решения; итоговый HTTP прогон и PostgreSQL regression повторены на этом source. + +| AC | Evidence | Verdict | +| --- | --- | --- | +| SADM01 | Direct API tests captain/member/foreign manager/expert/anonymous/staff, чужие и missing IDs, mode/input/query; настоящие HTTP 401/403 и private headers | PASS | +| SADM02 | Точные counts/SolutionRef, duplicate IDs, null token для all-submitted, incomplete bulk; HTTP hash всей fixture domain выборки до/после preview | PASS | +| SADM03 | Late captain 409 и manager 200, exact accepted-only snapshot/0/False, file URL и form snapshot; реальные обе формы | PASS | +| SADM04 | Required/case/registration/minimum tests, shared submission/file regression; unverified file на втором решении не обходит finalize и откатывает первое | PASS | +| SADM05 | Actor/program/action/IDs/signature/expiry/replay tests; семь изменений name/answers/member role/field/policy/file/profile без version bump вызывают conflict; actual stale/replay 409 | PASS | +| SADM06 | Mixed bulk сохраняет прежнюю дату/snapshot; incomplete блокирует всю выборку, ошибка окончательной проверки файла откатывает уже финализированного соседа | PASS | +| SADM07 | Два новых PostgreSQL races: confirm×confirm и confirm×captain PATCH; оба worker реально ждут program row lock по транзитивным pg_blocking_pids до снятия блокировки, допустимые ordered outcomes/snapshot проверены | PASS | +| SADM08 | Общая сдача/команда/файлы/форма и legacy actions входят в final 325 PostgreSQL tests; normal captain final deadline до/после I/O сохранен в source и прежних assertions | PASS | +| SADM09 | C00 125/47/185, прежние 120/45/161 структурно неизменны, три файла byte-identical; 30 новых wire tests, final test:ci 2793/479 + strict types | PASS | +| SADM10 | Final 42 actual HTTP/JWT checks на новых программах 19/20, решения 26/28 и anchors 27/29; прежняя выборка 11 domain моделей/Project не изменилась. Angular UI не добавлялся | PASS в API scope | + +Checked by tests: 17 новых tests; промежуточный широкий прогон 324 и final 325 PASS. В frontend final `npm run test:ci`: 2793 tests/479 files и strict types PASS. Checked by build/static: Angular development build, scoped ESLint, backend Black/Flake8, Django check, makemigrations --check/--dry-run и C00 validator PASS. Locked frontend format выполняется после добавления новых файлов. + +Checked through running API: [отчет](../qa/program-solutions-submit-actions-20261025/README.md), actual Django/PostgreSQL/JWT на localhost 8877. Обе формы: настройка/создание/сохранение, обычный anchor submit, закрытие срока и creation flag, captain 409, preview read-only, late draft PATCH → stale proof 409, fresh mixed confirmation 200, replay 409, all-submitted/null token и frozen PATCH. До/после hash прежних 11 моделей совпадает. Токены в evidence не сохраняются. + +Review verdict: готово в описанном API scope, actionable BLOCKER/MAJOR/MINOR findings не осталось. Новых UI/states/layout нет, Design QA неприменим этому API checkpoint. D02/D03, административные изменения после сдачи, конкурсная публикация и общий QA01/REL01 остаются открытыми. Собственный CI нового commit проверяется отдельно, старый зеленый 6db9050 его не заменяет. Merge/deploy/real program rollout не выполнялись. diff --git a/docs/product/program-solutions-be08.md b/docs/product/program-solutions-be08.md new file mode 100644 index 00000000..ddd994e7 --- /dev/null +++ b/docs/product/program-solutions-be08.md @@ -0,0 +1,105 @@ +# BE08: общие входы, уведомления и публикация + +Актуализация C00 1.21.0: независимый путь **сданных конкурсных решений** добавлен в существующие publisher/public GET. Конкурсный список теперь дает200, а не временный404; submitted материалы/состав берутся из действующего snapshot. [CPUB scope и проверки](program-solutions-competitive-publication.md). Несданные конкурсные черновики по-прежнему ожидают отдельного решения пользователя и не публикуются. Нижний PUB блок описывает первоначальный C00 1.11 noncompetitive checkpoint, его прежняя граница competitive404 заменяется CPUB; закрытые оценки не открываются. Полный первый выпуск остается незавершенным. + +Текущий статус C00 1.18: исключение принятого участника до/после сдачи с историей реализовано отдельно в [BE08B](program-solutions-be08-member-actions.md). BE08C1/C00 1.19 добавил [снятие/архив с retention](program-solutions-be08-archive.md). Оставшийся scope — удаление регистрации и согласованный organizer/public UI, полный QA01/REL01. + +Актуализация scope 25.10.2026: пользователь согласовал перенос административных исключений после сдачи (change_case/исключение участников/удаление или отвязка заявки), отдельно потребовав сохранить старые программы с заявками-проектами и их данные. [Решение и оставшаяся реализация](program-solutions-be08-admin-exceptions.md). Legacy_project сохраняет прежнюю модель и маршруты; автоматического переноса/удаления Project нет. Обычный captain остается ограничен snapshot/frozen правилами. C00 1.17/BE08A расширил signed case action на сданные решения и добавил приватную историю before/after; обычные guards сохранены. [Контракт и модель](program-solutions-be08-case-history.md). C00 1.18/BE08B добавил исключение участников с историей. BE08C1 реализовал отвязку/архив/retention; удаление регистрации еще предстоит. Поздняя первая сдача организатором уже реализована отдельно. [QA01](program-solutions-qa01.md) сохраняет26 исходных AC; это не закрытие BE08/QA01/REL01. + +Статус: первый блок реализован после checkpoint c15a995 (API BE07); общая новая сущность и первый выпуск еще не готовы. Актуализация 25.10.2026: D01 согласован, первый UI FE07 опубликован в draft frontend #505, продолжается подключение общего кабинета. Исторические проверки блока ниже относятся к указанным commit. + +Current behavior: partner_programs/selectors.py и services/analytics.py определяют команду по фактическому Project leader/Collaborator, а не по project_id регистрационной анкеты. Эти selectors используются mailing/tasks.py, внимание и аналитика должны считать тех же людей. project_rates/workspace.my_programs и expert_cabinet используют реальные ProjectExpertAssignment; номера Expert и CustomUser различаются. Административные удаления защищают оценки и сохраняют общую программу. project_rates/services.\_send_project_rated_email использует vacancy.mapping/tasks; сообщение отправляется капитану/руководителю после оценки. partner_programs/services/publishing.py периодически делает недрафтовые Project публичными при publish_projects_after_finish и завершенной программе; повторный запуск не меняет уже опубликованную работу. Отключение флага не отзывает состоявшуюся публикацию. + +Target: в solution mode те же сценарии используют ProgramSolution и принятых ProgramSolutionMember; pending не становится членством. Legacy selectors/response shapes сохраняются для legacy_project. Уведомление оценки имеет отдельный solution_rated payload с program_id/solution_id и ссылкой на существующий редактор решения; отправка планируется после commit. Откат и отклоненный POST не отправляют письмо. Публичные материалы нового формата не создают Project и не входят в каталог/счетчики обычных проектов. Старые ссылки не меняются. + +Публикация материалов и раскрытие оценок — разные правила. Source search по всем production Python consumers ProjectScore не нашел публичного/участнического endpoint результатов: значения доступны собственному эксперту и организатору; publish_projects_after_finish меняет только Project.is_public. Поэтому новое публичное представление материалов не включает оценки, рейтинг экспертов или административный results DTO. Новое правило раскрытия оценок из этого флага не выводится. + +План: (1) общая SQL-проекция принятого состава для selectors/списков внимания; (2) отдельный email payload и after-commit delivery; (3) кабинет эксперта и административные операции с настоящими Solution/Assignment IDs; (4) модель/контракт публичных материалов и задача публикации; (5) адресные проверки scope/состояний/rollback/повторного запуска, затем регрессия и review. Новые публичные/кабинетные ответы дополняются в C00 до реализации; существующие payload не переименовываются молча. + +AC02/04/20: правильная текущая заявка/программа и принятый состав; AC18: свои/скрытые оценки; AC19/22: никакого дублирования Project и прежний legacy путь; AC23: неконкурсное сохранение. AC нового блока: капитан и accepted member исключены из «без команды», pending остается в списке; email содержит обе настоящие ссылки и отсутствует при rollback; единая пагинация кабинета не смешивает IDs/счетчики; публикация выполняется после срока и флага, повтор не создает событий, материалы читаются без доступа к приватным оценкам. Финальные verdict/evidence добавляются по выполненным проверкам. + +Реализован первый блок: SQL membership projections и mailing/attention selectors, after-commit solution_rated, реальные счетчики /evaluation/programs/, единая страница GET /evaluation/applications/ по C00 1.4.0, стандартные Django admin представления заявки/оценок и массовые назначения. Readonly состав/ответы/приглашения и сданный snapshot нельзя переписать из admin. Название черновика меняется под блокировкой программы с повышением version. Частичная оценка защищает назначение от одиночного и массового удаления. Migration project_rates.0009 и partner_programs.0032 меняют только Meta названия; новая машина статусов не добавлялась. + +Административные действия, адресованные старому program-project link, в solution mode отклоняются; удаление регистрации старым handler также блокируется, чтобы не оставить решение с несогласованным составом. Для нового формата еще нужны отдельные подписанные команды с реальными SolutionRef и согласование с правилом неизменного сданного состава. Это незакрытая часть BE08, не готовая функция удаления участников решения. + +Проверено: затронутый PostgreSQL regression — 312 PASS, затем 21 профильный test после review — PASS (включая 7 Django admin tests и защиту переноса критерия между программами). Для локального полного набора использована отдельная test_procollab_ci_unicode с ICU ru-RU: прежняя C locale не преобразовывала регистр кириллицы, тот же сбой подтвержден на неизменной HEAD проекции. Локальный harness сохраняет штатный RUNNING_TESTS, отключая только конкурирующие Windows file log sinks; domain, async flow и PostgreSQL проверки не подменяются. Ограничения 4/14 запросов сохранены; выбор источника команды выполняется SQL CASE без дополнительного запроса. Black/Flake8, migration consistency и C00 PASS. Предыдущий c15a995 прошел Linux CI: 1508 backend tests и 39 locking tests, общий CI тоже PASS. Новый commit требует своего CI. UI FE07/FE08 не реализован до согласования дизайна. + +Checked through running HTTP API: КЧ и хакатон, JWT двух экспертов/организатора/участника, прежний полный путь оценки и общий кабинет с limit=1/offset=1, корректными SolutionRef и счетчиками; /evaluation/programs/?include=projects сохраняет прежнюю проекцию. Личные оценки и административный результат не открываются участнику. Файловый storage локальный; email backend locmem, внешние сообщения и реальные программы не использованы. + +Requirement audit: AC04/20 PASS для membership/selectors/нового кабинета; AC18 PASS для собственных оценок и закрытых результатов в проверенных API; AC22 PASS для затронутого legacy regression. AC02/19/23 остаются PARTIAL до всех административных входов, публикации и BE09. Review не обнаружил оставшихся блокирующих замечаний в текущем checkpoint; общий первый выпуск не готов. При review исправлены лишний запрос определения режима, согласованность cabinet mock/filter, сохранение revision при admin правке и гонка смены программы критерия до блокировки. + +Бизнес-вопрос закрыт пользователем 25.10.2026: при включенной публикации открывать решения внешним посетителям в разделе «Решения» самой программы, включая сохраненные неконкурсные материалы без отдельной сдачи. Хранение — ProgramSolution/ProgramSolutionAnswer и собственные связанные модели; файлы остаются в существующем хранилище. Публикация меняет доступ, не переносит работу в Project/каталог. Закрытые оценки, регистрационные данные и pending приглашения не являются публичными материалами. Контракт и реализация неконкурсной ветки описаны ниже; конкурсная публикация и public UI еще впереди. Новый draft нельзя механически приравнивать к старому Project.draft. + +## Входы программы: контракт до реализации 25.10.2026 + +Before: GET /programs/{id}/ не передает формат хранения, обе экспертные ссылки Angular всегда строят projects-rating. After: в существующие member/unregistered ответы добавляется read-only solution_storage_mode со значениями legacy_project/solution. Это публичная настройка типа заявки, не доступ к форме/материалам/оценкам. Остальные поля и роли не меняются; current_application остается прежним Project DTO. Старые клиенты игнорируют добавочное поле, новый frontend при его отсутствии использует прежний маршрут. Сначала backend, затем frontend; миграций нет. Операции C00 решения не меняются. + +ENT01: оба вида ответа detail передают реальное значение, включая эксперта без регистрации; запрос не дает новых прав на материалы. ENT02: верхняя кнопка и ссылка экспертного виджета ведут в solutions-rating только при явном solution, иначе projects-rating. ENT03: реальные browser обеих локальных программ и legacy, query/права и regression tests. Это маршрутизация входов; метрики ролевого виджета и остальные BE09 остаются отдельной незавершенной работой. + +Реализовано: read-only поле в базовом member/unregistered serializer, без миграций и изменения role checks. ENT01 PASS: 30 PostgreSQL tests (новый routing/privacy/readonly, прежние detail и role widget) и реальный HTTP эксперта обеих форм. ENT02/03 PASS: Angular widget tests и browser верхняя кнопка/виджет → нужный список → возврат для КЧ program=3, хакатона=4 и legacy=5; списки 320/390/768/1440 без overflow/JS errors. Эксперту без регистрации по-прежнему не возвращаются description/links. Black/Flake8 и git diff --check PASS. Full frontend regression/build/format фиксируется в парном отчете. Это не проверка/готовность новых счетчиков виджета, публичной страницы или всего первого выпуска. + +## Публикация неконкурсных решений + +Независимый блок подтвержденного поведения. Конкурсная публикация остается незавершенной: вопрос о несданных черновиках уже задан пользователю, ответ не получен. Этот блок не выбирает правило за пользователя и не объявляет публикацию всей feature готовой. + +### Current behavior и source + +`services/publishing.py` выбирает завершенные программы с publish_projects_after_finish, затем делает связанные недрафтовые Project публичными. Повтор не меняет уже опубликованные записи; выключение флага или перенос даты после состоявшейся публикации не отзывает Project.is_public. Существующая Celery task вызывается ежедневно в 06:00, ее имя/возвращаемое число не меняются. Program detail читается анонимно через IsAuthenticatedOrReadOnly без дополнительного draft-фильтра. Новый public API не вводит отдельную политику видимости самой программы. + +У неконкурсного решения нет отдельного финального submit: ProgramSolution.status=draft обозначает хранение сохраненной заявки, а не запрет публикации. require_mutable разрешает дальнейшие изменения капитану; публикация сама по себе не должна заморозить такую работу или создать Submission/Project. Submitted-данные, если такая запись уже существует, по-прежнему читаются из неизменяемого снимка. + +### Target behavior и правила + +В ProgramSolution добавляется служебное nullable published_at (не editable через API/admin). NULL по умолчанию сохраняет существующие решения закрытыми. Отдельный publisher выбирает только solution + noncompetitive + publish_projects_after_finish + datetime_finished <= now; под блокировкой программы ставит дату только неопубликованным решениям. Не проверяет искусственную сдачу/полноту обязательных полей. Повтор сохраняет первую дату. После публикации публичность определяется сохраненным маркером: выключение флага не отзывает состоявшуюся публикацию. Дальнейшие обычные правки сохраненной неконкурсной работы видны в публичных материалах, без смены версии из-за самого publisher. + +Данные остаются в ProgramSolution/ProgramSolutionAnswer/accepted members, файлы — в прежнем UserFile/CDN. Project не создается и не становится публичным из-за публикации solution-программы. Старый publisher ограничивается legacy_project, сохраняя прежнее поведение для старого режима; исторические проектные ссылки/анкета в новом режиме не публикуются как побочный эффект. BE10 для реальных программ остается отдельной согласованной миграцией. + +Публичное представление показывает application=SolutionRef, дату публикации, определения полей/ответы и принятый состав с минимальным User (id, first_name, last_name, avatar_url), ролью/специализацией и is_captain. Капитан один раз. Из снимка и текущих моделей берутся только эти разрешенные ключи. Нет оценок, назначений, рейтинга, регистрационной анкеты, email/даты рождения профилей, pending приглашений, token, permissions/readiness/version и внутренних данных организатора. Настроенные поля решения публикуются как материалы с сохраненным содержимым. Форма/ответы одной программы; 0 и False остаются значениями ответа. Текущий файл не копируется и не получает новый ID. + +### Contract before и after + +В C00 1.10.0 публичных материалов нет. C00 1.11.0 добавляет два read-only GET с security=[]: `/programs/{program_id}/published-solutions/` и `/programs/{program_id}/published-solutions/{solution_id}/`. Блок реализует неконкурсные программы; competitive/legacy пока 404, это граница незавершенной реализации конкурсного правила, а не новый бизнес-запрет. + +List: limit 1–100 (default 20), offset >=0, search <=200 по отображаемому имени (snapshot для submitted). Ответ program_id/count/next/previous/results; карточка содержит SolutionRef, published_at, минимального капитана и accepted_count. Сортировка published_at/id, найденные работы не ограничиваются первой страницей. Detail: application/published_at/fields/answers/team. Неопубликованная работа, чужая программа, неизвестный/unsafe ID дают один C00 not_found без раскрытия существования. Неверные/лишние query — C00 400. Невалидная переданная авторизация может дать C00 401; анонимный запрос без нее разрешен. Cache-Control no-store. GET не публикует и не пишет данные. Менеджер/капитан не получают через этот путь обход публикации, приватные API сохраняются отдельно. + +Affected systems: nullable migration + partial program/publication index, publisher/service exports, отдельная Celery task с прежним временем 06:00, public projection/API/URLs, C00/generated frontend DTO/fixtures и API layer. Новый публичный Angular экран требует отдельного дизайна и approval; D02 организатора также остается pending. Порядок: spec/C00 → migration/model/publisher → safe public projection/API → tests/legacy regressions → API layer/wire tests → review/draft PR. Rollout: migrations → backend → frontend, без переключения реальных программ и без выполнения production publisher в этой задаче. + +| AC | Проверяемое поведение | +| --- | --- | +| PUB01 | Только завершенная solution noncompetitive с включенным флагом получает published_at; до срока/без флага/legacy/competitive ничего не публикуется | +| PUB02 | Первая дата сохраняется при повторе и двух конкурентных publisher; позднее включение работает, выключение после публикации не отзывает публичность | +| PUB03 | Сохраненное draft/частичные или пустые материалы не требуют submit; publisher не меняет status/version/updated_at/состав/файлы, последующая разрешенная правка видна публично | +| PUB04 | Анонимные list/detail дают реальные SolutionRef, stable ordering/count/search/pagination, current или submitted snapshot; C00 shape/private-data allowlist соблюдены | +| PUB05 | 0/False/file URL и accepted team/captain сохраняются, pending/регистрации/email/оценки/служебные данные не попадают в JSON | +| PUB06 | Непубличные/чужие/unknown/unsafe IDs скрыты 404 для всех ролей; invalid query 400; старый private API не становится публичным и write payload не может задать published_at | +| PUB07 | Новый job и legacy publisher разделены по режиму, новые решения не создают/не публикуют Project; старая публикация из link/profile и периодическая task сохраняются | +| PUB08 | Миграция оставляет старые решения private, индекс соответствует public query; гидратация после pagination и bounded queries исключают N+1, большой load benchmark не подменяется этим тестом | +| PUB09 | C00/два repo/generated types/fixtures и frontend API layer синхронизированы; read-only операции поддерживают anonymous, 0/False/nullable next/errors без Project DTO | +| PUB10 | Конкурсное правило не подставлено вместо ответа пользователя, новая UI-реализация не начинается без дизайна/approval; основной goal/BE08/QA01/REL01 не объявляются завершенными | + +### Verification и review публичного API + +| AC | Evidence | Verdict | +| --- | --- | --- | +| PUB01 | API/service tests: flag false, future/exact finish, competitive и legacy не публикуются; сохраненный noncompetitive draft получает marker | PASS | +| PUB02 | Повтор/позднее включение, flag off и перенос даты после публикации; два PostgreSQL потока с Barrier дают counts 0/1 и одну первую дату | PASS | +| PUB03 | Status/version/updated_at/snapshot/files/project count до и после publisher неизменны; empty/partial без submit, разрешенная правка после публикации видна; stale full save сохраняет marker | PASS | +| PUB04 | Anonymous list/detail C00, stable count/order/search/next/previous, submitted snapshot вместо поздних live значений; actual HTTP публичных материалов | PASS | +| PUB05 | 0/False/URL файла, accepted/captain once и отсутствие pending/registration/profile email/birthday; snapshot extra keys/неподходящие object answers/file metadata не раскрываются | PASS | +| PUB06 | Одинаковый 404 для всех ролей на непубличной работе, foreign/unknown/unsafe IDs, query 400, private read 401/404 и public write 405; API/model/admin marker readonly | PASS | +| PUB07 | В одном publisher запуске старые link/profile проекты legacy публикуются, обе исторические Project ссылки solution-программы остаются private; новый job idempotent и расписание совпадает со старым | PASS | +| PUB08 | MigrationExecutor 0032→0033 с существующим решением оставляет published_at NULL; SQL index применен, list 4 queries без роста на 9 работах, detail не более 6 и без чтения project_rates | PASS | +| PUB09 | C00 1.11.0 120/45/144, прежние 116/43/128 сохранены; 25 новых wire tests, full frontend 2720 tests / 477 files, strict C00 types/build/scoped ESLint PASS | PASS | +| PUB10 | Этот checkpoint реализует только подтвержденную неконкурсную ветку; конкурсные черновики и новый public UI остаются открытыми, default legacy/реальные программы не переключались | PASS в указанном scope | + +Checked by tests: **19 новых publication tests**; итоговый широкий PostgreSQL прогон foundation/team/invitations/forms/answers/submission/entrypoints/routing/widget/cases/analytics/reminders/publication, legacy publishing, solution evaluation и chat/Swagger permissions — **229 tests PASS**. В окончательном focused прогоне publication + legacy publishing + staff Swagger — 24 PASS, включая эти 19. Logs: `C:/Users/evsee/AppData/Local/Temp/procollab-solution-publication-final-regression.log` и `procollab-solution-publication-final-tests.log`. PostgreSQL 18/ICU ru-RU test DB; concurrency и настоящий schema transition выполнены, не заменены SQLite проверкой. + +Checked through running API: **16 реальных HTTP checks** на localhost:8871. Для синтетических program=6/solution=4 временно включены flag и прошедшая дата, scoped publisher поставил marker. Anonymous list/materials/team, search/limit, private allowlist, чужие/unsafe ID, query 400 и bad JWT 401 прошли C00; private solution read остался 401. После выключения флага marker продолжил давать доступ. В finally восстановлены flag/finish/marker, сохраненные данные/версии/состав/файлы и Project public state не менялись. После восстановления list пуст и detail 404; backup-файл удален. Evidence: `design-artifacts/program-solutions-20261024/expert-v1/implementation/solution-publication-http-evidence.json`. Это проверка API, не нового public Angular экрана. + +Checked by source/review: mode/date/flag → program lock → nullable marker update, отдельная Celery task и allowlist GET прослежены. На review закрыты перезапись marker устаревшим Model.save, смешение legacy Project ссылок, выдача произвольного JSON вместо примитивного ответа и лишние метаданные файла. Настроенное содержимое допустимого типа сохраняется: новые required/min/max/options не вводят искусственную сдачу или автоматическое переписывание ответа. Private mutation/permission/snapshot contracts не расширены. Blocking findings этого API-блока не осталось. + +Checked by build/static: Angular development build и scoped ESLint PASS; backend Black/Flake8, C00 validator, makemigrations --check, manage.py check и diff checks PASS. Migration 0033 применена к отдельному локальному runtime и test DB; production migration/deploy не выполнялись. Not checked: новый public browser UI, production/CDN download, large-volume memory/load benchmark, конкурсная публикация и первый выпуск целиком. Собственный Linux CI нового commit требуется; исправление Swagger 3e9171b проверяется отдельно и не подменяет CI этой миграции. + +## Первая сдача организатором: C00 1.14 + +Signed preview/apply перенесен для существующей поздней первой сдачи. Только action=submit, реальные Solution IDs, общая проверка/finalize и неизменность уже сданных данных. [Spec, SADM01–SADM10 и review](program-solutions-be08-submit-actions.md), [42 actual HTTP checks и source provenance](../qa/program-solutions-submit-actions-20261025/README.md). Final 325 PostgreSQL tests (17 новых), frontend 2793/479 + strict types, build/static PASS. UI D02 и административные изменения после сдачи здесь не реализованы; общий BE08/QA01/REL01 открыт. Новый source требует собственного CI, старый 6db9050 его не заменяет. diff --git a/docs/product/program-solutions-be09.md b/docs/product/program-solutions-be09.md new file mode 100644 index 00000000..2818f9f1 --- /dev/null +++ b/docs/product/program-solutions-be09.md @@ -0,0 +1,315 @@ +# BE09 Аналитика, виджеты и выгрузки решений + +## Фильтр настраиваемого поля и выгрузка найденных: до реализации + +Problem/current: manager solution list поддерживает search/case/scope/submission, а XLSX analytics принимает только kind и выгружает всю программу. Фиксированная презентация не описывает материалы любой формы. Источники: solution_analytics_api.ManagerSolutionsQuery/SolutionManagerSolutionsView/ManagerExportQuery, SolutionAnalyticsContext.applied_form_and_answers/coverage и solution_schema.empty_value, solution_program_report.write_solutions. D02 показывает proposed UI фильтра; approval интерфейса еще ожидается. Этот этап расширяет только контракт/backend/frontend API layer. + +Target/C00 1.12.0: GET management/solution-analytics/solutions/ добавляет optional field_id (safe integer) и answer_state=all/present/missing/not_applicable (default all). Без нового фильтра поведение прежнее. present/missing применяются только к решениям, в applied форме которых есть stable field ID; not_applicable — только где ID отсутствует. Applied форма — submitted snapshot/current draft, как в coverage. empty_value сохраняется: null/пустой/пробельный текст отсутствуют, 0/false являются ответами; фильтр не вводит проверку готовности/типов/доступности файла. Field ID разрешен из текущей формы или исторической формы хотя бы одной сданной работы этой программы; неизвестный/чужой ID дает одинаковый validation_error 400. answer_state, отличный от all, требует field_id. field_id+all валидирует ID без ограничения выборки. + +GET management/solution-analytics/export/ для kind=solutions/submitted принимает те же непагинационные search/scope/submission/case_scope/case_name/field_id/answer_state. Одна функция выбирает решения для JSON и книги; выгружаются все найденные, включая за пределами страницы. kind=submitted дополнительно пересекает выборку со submitted. Листы Решения/Команды/Поля формы/Ответы решения содержат только выбранные IDs; no-match дает корректную пустую книгу. kind=program/scores сохраняют прежний полный scope и отклоняют непустые фильтры с 400, чтобы глобальная сводка/оценки не становились частичными молча. limit/offset в export по-прежнему отклоняются. Ответы, заголовки, порядок created_at/id, XLSX writer/защита от формул и old callers с одним kind сохраняются. + +Roles/permissions: существующие manager/admin, конкретная программа и solution mode; participant/expert/outsider/anonymous не получают list/XLSX. Приватные no-store/C00 errors сохраняются. Другой program/field не раскрывается; значения и регистрационные данные не добавляются в JSON list. Data/migrations: не меняются, только readonly выборка. Конкурентная редакция формы обрабатывается обычным свежим GET; новый writer/lifecycle не вводится. Public API/публикация/реальные программы не затрагиваются. + +| AC | Предусловие и результат | +| --- | --- | +| FLT01 | Без новых параметров list/four export kinds и legacy endpoints сохраняют ответы/состав/порядок | +| FLT02 | Поле применимо: present/missing различают null/пустой/пробельный текст и 0/false, без readiness проверки | +| FLT03 | Новое поле после сдачи — not_applicable у старого snapshot; submitted ответы/метаданные не подменяются live; исторический ID своей программы допустим | +| FLT04 | Search/case/submission/attention и field filter пересекаются до pagination/count; links сохраняют фильтр | +| FLT05 | XLSX solutions/submitted повторяет ту же выборку без pagination, все четыре связанных листа scoped; submitted — дополнительное пересечение, no-match корректен | +| FLT06 | Missing field ID/unsafe/foreign/unknown/invalid state и фильтр kind=program/scores дают C00 400; invalid case и unknown query не игнорируются | +| FLT07 | Все реальные роли/программа/mode/private headers прежние; GET и export не меняют сохраненные данные/файлы, отсутствует N+1 | +| FLT08 | C00/оба repo/types/fixtures и API layer согласованы; query snake_case, Blob/error через текущий interceptor; строгие типы не допускают pagination/export и field filters для program/scores | + +Plan: зафиксировать additive C00/сценарии → общий selector/strict serializers → scoped writer → PostgreSQL matrix/старые регрессии → frontend API types/wire tests/build → real HTTP/XLSX → requirement review/draft PR. Backend → frontend rollout без миграции. Новые Angular компоненты/фасады/SCSS и решения по открытым D02/D03/конкурсным черновикам в этом блоке не реализуются. + +### FLT verification и requirement review + +| AC | Реализация и evidence | Verdict | +| --- | --- | --- | +| FLT01 | ManagerSolutionFilters/filtered_solution_rows, no-filter/default-all tests и прежние manager/export/legacy tests; прежние response schemas/144 сценария сохранены | PASS | +| FLT02 | Существующий empty_value: absent/null как удаленный ответ, пустой/пробельный текст, 0/false, file/repository. DB NOT NULL не ослаблен | PASS | +| FLT03 | applied_form_and_answers: новый required после сдачи, исторический ID только в snapshot, поздние live имя/значение не меняют результат | PASS | +| FLT04 | Одна выборка до count/slice; case/submission/attention/search intersections; 25 работ, offset 20/limit 5, ссылки с field_id/answer_state | PASS | +| FLT05 | ID scope всех четырех листов, JSON/XLSX parity, выгрузка за пределами страницы, submitted intersection/empty books, числовой 0 и boolean false | PASS | +| FLT06 | Invalid pair/value/blank/unsafe/foreign/unknown/case/extra/pagination и nonempty program/scores filters — C00 400; явный blank больше не пропускается DRF HTML parser | PASS | +| FLT07 | Manager/admin и отрицательные роли, real JWT/anonymous трех программ; данные/файлы unchanged, число SQL list/export одинаково для 1 и 9 работ | PASS | +| FLT08 | C00 1.12.0 120/45/158 byte-identical; port/adapter snake_case/safe ID, 20 новых wire и strict export union/negative type cases | PASS | + +Checked by tests: **15 новых PostgreSQL tests PASS**, окончательный общий прогон всех partner_programs test_solution* + legacy services + solution evaluation + chat/Swagger permissions — **244 PASS**, включая эти 15. Log: `C:/Users/evsee/AppData/Local/Temp/procollab-solution-field-filters-regression.log`. Targeted frontend: **86 PASS** (20 новых field-filter wire + 61 manager/C00 + 5 прежних export); полный frontend **2754 tests / 478 files**, strict C00 types PASS. Logs: `procollab-solution-field-filters-wire.log`, `procollab-solution-field-filters-fe-tests.log` в Temp. + +Checked through running API: **143 реальных HTTP/JWT/anonymous checks** КЧ program=3, хакатона=4, неконкурсной program=6. Field/state counts совпадают с coverage; XLSX solutions/submitted — с JSON и связанными IDs; C00 400/401/403/private headers и прежние global exports проверены. Domain mutations 0: solutions/answers/team/invites/files/programs/Project public state до/после совпадают. JWT AccessToken создается без refresh/outstanding записи. Evidence: `design-artifacts/program-solutions-20261024/expert-v1/implementation/field-filters-http-evidence.json`. Локальный backend перезагружен с прежним local storage/locmem mail, production не затрагивался. + +Checked by source/review: JSON и XLSX используют одну выборку; known IDs ограничены current/historical формами своей программы, missing исключает not_applicable. Экспорт не обрезается страницей, program/scores не становятся частичными молча. Закрыто принятие пустого query DRF parser; frontend types запрещают filtered program/scores/pagination и guard отклоняет unsafe field ID до HTTP. Тестовая попытка SQL NULL исправлена на удаление ответа, как настоящий API; ограничения БД сохранены. Blocking findings этого блока не осталось. + +Checked by build/static: Angular development build PASS с прежними Sass deprecation warnings, scoped ESLint 0 errors, locked Prettier 3.8.3 по 1969 LF-normalized files, Black/Flake8/C00/diff checks PASS; makemigrations --check без изменений, manage.py check PASS. Предшествующий e5a6952: PostgreSQL CI 1637 full-suite + 39 locking и общий CI success; это не CI следующего commit с фильтрами. Собственный CI требуется после push. + +Not checked: manager/public Angular UI (D02/D03 approval pending), browser новых controls, production/CDN/Excel пересчет, large-volume memory benchmark. Query-count test не доказывает постоянную память. BE09 и первый выпуск целиком не объявляются завершенными. + +Первый блок: XLSX результатов. Общая задача BE09 еще не завершена. + +## Источники и контракт до реализации + +- Действующая выгрузка: `ManagementResultsExportView` → `results_data` → `export_results`. Доступ организатора программы; фильтры текущей выборки или scope=all, места до фильтрации, два листа, только полные оценки в средних. Текстовые ячейки не становятся формулами. +- До этого блока BE07 уже давал результаты реальных ProgramSolution через `project_rates.solution_results.results_data`; математика общая со старым Project, имена и кейсы из сданного snapshot. XLSX отсутствовал. +- C00 1.5.0 добавляет GET `/programs/{program_id}/management/solution-results/export/`: бинарный XLSX, JSON C00 для ошибок. Query как GET solution-results плюс scope=current/all. Права, submitted-only и program scope проверяются сервером. Старые endpoints и DTO не меняются. +- Успешная книга содержит «Решение» и «ID решения»; ни один solution_id не переносится в поле project_id. Frontend получает Blob через существующий ApiService.getFile, без нового UI до согласования дизайна. + +## Проверяемые критерии блока + +| ID | Предусловие и ожидаемый результат | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| EXP01 | Организатор программы solution получает XLSX с двумя листами и настоящим ID решения | +| EXP02 | Эксперт, участник, посторонний и аноним не получают закрытую книгу; другая программа и legacy mode не смешивают источники | +| EXP03 | scope=current повторяет search/case/status/order; scope=all снимает фильтры, сохраняя порядок; неверный scope и лишние query отклоняются | +| EXP04 | Места и цифры совпадают с JSON; черновики отсутствуют, неполные оценки не входят в формулы средних, 0/False сохраняются | +| EXP05 | Имена/комментарии, начинающиеся с =, записываются как текст; числовые значения и расчетные формулы сохраняют тип | +| EXP06 | Пустые результаты и отсутствие числовых критериев дают корректную книгу без выдуманного рейтинга | +| EXP07 | Существующая выгрузка проектов сохраняет заголовки, ID, формулы и результаты | +| EXP08 | Frontend передает snake_case query через API port/repository/adapter/use case и возвращает исходный Blob; JSON ошибки в Blob декодируются в C00 | + +## План + +Контракт в обоих репозиториях → общий writer книги с явными ключами решения/проекта → scoped API → API-слой frontend → PostgreSQL и wire tests → regression старой книги → review и draft PR. + +Оставшиеся BE09: аналитика решений/применимость материалов, ролевой виджет, внимание и прочие выгрузки. Экспорт не раскрывает результаты участникам и не определяет публикацию работ. + +## Проверка и review первого блока + +- Checked by source: общий writer параметризован ключами `solutions/solution_id` и `projects/project_id`; никакой проекции решения в фиктивный Project нет. API проверяет организатора и режим программы; результаты берутся из того же сервиса, что JSON. Все старые операции, схемы и независимые сценарии C00 семантически сохранены. +- Checked by tests: 23 PostgreSQL tests нового/старого экспорта и паритета PASS. Широкий повторный прогон: 201 PostgreSQL tests (`project_rates`, старые результаты, manager analytics и role widget) PASS. В первой широкой команде два неверных имени модулей дали loader errors; после исправления команда целиком прошла. Black/Flake8 и git diff --check PASS. C00: 74 схемы, 34 операции, 57 сценариев PASS, файлы в обоих репозиториях byte identical. +- Checked by build: frontend API-слой собирается в Angular development build. Полный Vitest: 2568 tests / 467 файлов PASS, strict C00 types PASS. Бинарный успех проходит через настоящий CamelcaseInterceptor; JSON ошибка в Blob проверяется на границе HTTP. +- Checked through running API: локальный Django/PostgreSQL/JWT на вымышленных данных КЧ и хакатона. Настоящий XLSX читается openpyxl; ID, два полных эксперта, место 1 и JSON score 0.5 согласованы; формулы/числовые ячейки сохранены; эксперт/участник получают 403, неверный scope — JSON 400. Реальная почта и production не использовались. +- Checked in browser: новый продуктовый UI выгрузки не реализован; согласование дизайна не пройдено. Полный Linux CI нового commit запускается после push. Формулы сохранены и проверены как формулы; пересчет книги в Excel отдельно не запускался, как и прежде writer использует fullCalcOnLoad. + +EXP01–EXP08: PASS для API/выгрузки и API-слоя frontend. Review не выявил незакрытых дефектов этого блока. Это не приемка всей BE09 или общей feature; PR остается draft. + +## Следующий блок: ролевой виджет решения + +Current: GET /programs/{id}/analytics-widget/ правильно проверяет роль и считает участников через общие selectors, но работу/назначения/этапы по-прежнему читает из PartnerProgramProject/ProjectExpertAssignment. Маршрутизация экспертных входов исправляется отдельно через публичное read-only solution_storage_mode detail. Это не исправляет старые нулевые счетчики виджета. + +Target: сохранить endpoint, role priority organizer → expert → participant, отсутствие polling/записей, геометрию и controls. Legacy ответ остается прежним. Solution ответ получает явный storage_mode=solution и настоящие ссылки/показатели решения, без fake Project полей. До runtime реализации описать в C00 независимую схему ветки solution и сценарии всех трех ролей; frontend читает discriminated union. Сначала backend и frontend без включения реальных программ, затем общий QA. + +Источники правил: role_analytics.py; analytics.\_solution_rows (distributed требует завершенных реальных назначений, open отмечает evaluated по первой оценке); project_rates.evaluation_math.complete_values и solution_evaluation.snapshot_name/snapshot_case; selectors принятого состава BE08. Новый independent stage machine не вводится. Баллы, личности экспертов и регистрационные ответы в виджет не входят. + +| AC | Наблюдаемое требование | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| WID01 | Приоритет и программа роли прежние; outsider/foreign Expert IDs/клиентские role claims не дают доступа | +| WID02 | Команда определяется капитаном/принятым составом своей заявки, pending не дает членство; неоднозначность возвращает 409 вместо первой строки | +| WID03 | Участник получает SolutionRef и название/кейс сданного snapshot; чужая программа, Project и действующий live label не подменяют работу | +| WID04 | Стадии none/not_submitted/submitted/review/evaluated и not_applicable сохраняют смысл; неконкурсной не требуется искусственная сдача | +| WID05 | Expert assigned/remaining основаны на его реальных SolutionExpertAssignment; незавершенные/not_ready остаются в остатке, 0/False завершают критерии, open/noncompetitive counters null; deadline не меняет факт оценки | +| WID06 | Organizer считает ProgramSolution, submitted и уникальных зарегистрированных людей без принятой команды; неприменимое submitted в noncompetitive null | +| WID07 | GET не записывает данные, число запросов ограничено и не растет на каждую работу; legacy regression и private payload checks проходят | +| WID08 | Три прежние UI-роли показывают корректные подписи/ссылки/нулевые и неприменимые состояния; browser обеих форм/mobile, source/tests/build/review фиксируются отдельно | + +## Проверка и review ролевого виджета 25.10.2026 + +Реализован отдельный solution_role_analytics, подключенный после прежней проверки роли. C00 1.7.0 фиксирует один существующий GET и две независимые формы успешного ответа: legacy остается прежним, solution содержит storage_mode и настоящие SolutionRef/счетчики. Ошибки существующего endpoint сохраняют DRF detail и status; клиент показывает безопасный текст по статусу. Успешный приватный ответ имеет Cache-Control: private, no-store. Новых записей, событий или polling нет. + +| AC | Реализация / evidence | Verdict | +| --- | --- | --- | +| WID01 | require_widget_role до выбора сервиса; tests priority, outsider, foreign expert, query claims и anonymous | PASS | +| WID02 | scoped ownership query по captain/accepted members, максимум две строки; pending исключен, multiple/legacy-only возвращают 409; API tests | PASS | +| WID03 | snapshot_name/snapshot_case и признак кейса из сданной формы; test измененной live подписи/имени, API schema и реальные browser ссылки | PASS | +| WID04 | общий complete_values и прежние правила distributed/open; API tests draft/no criteria/first score/noncompetitive/after deadline, browser обеих форм и сохраненного noncompetitive | PASS | +| WID05 | настоящие назначения своего Expert.user, только собственные оценки; 0/False, второй эксперт/квота, not_ready и null tests | PASS | +| WID06 | ProgramSolution aggregate и принятый SQL membership; уникальные регистрации, pending, historical Project и noncompetitive null tests, browser реальные счетчики | PASS | +| WID07 | tests всех ролей: не более 14 queries, число не растет после добавления восьми работ, отсутствуют INSERT/UPDATE/DELETE; old body/schema/privacy regression | PASS | +| WID08 | прежняя геометрия/controls и SCSS, C00 union на frontend; 21 component + 8 HTTP wire tests, реальный browser трех ролей и 320/390/768/1440 | PASS | + +- Checked by source: legacy services не переписаны; поля решения не проецируются в Project. Ownership и строка читаются одним scoped query без второго unscoped get. Privacy ограничена ссылкой/именем/кейсом/стадией; критерии, оценки, эксперты и регистрационные ответы не сериализуются. Все прежние 74 схемы, 34 операции и 59 сценариев C00 структурно сохранены; OpenAPI/примеры/README обоих repos byte-identical. +- Checked by tests: финальный PostgreSQL прогон 214 tests (новые 15, legacy role widget, program detail и project_rates) PASS после review selector. Первый запуск финальной команды имел loader error из-за неверного имени test_role_widget; исправленный test_role_widget_api прогнан целиком. C00: 85 schemas / 35 operations / 72 scenarios PASS. Black/Flake8 пяти измененных Python файлов, makemigrations --check --dry-run и manage.py check PASS. +- Checked by build: frontend npm run test:ci — 2615 tests / 473 files и strict C00 types PASS; обычный Angular development build PASS, scoped ESLint 0 errors. Locked Prettier 3.8.3: 1961 LF-normalized files PASS; итоговый git diff --check обоих repos PASS. +- Checked in browser: реальные Angular/Django/PostgreSQL/JWT с вымышленными КЧ, хакатоном и неконкурсной программой. Три роли, открытие своего решения, пустая заявка/невыбранный кейс, 0/null/completed, Mont, 320/390/768/1440 без overflow; controlled network/retry и 409 без raw detail, legacy expert regression PASS. 0 изменяющих solution HTTP, JS errors отсутствуют. Evidence: design-artifacts/program-solutions-20261024/expert-v1/implementation/widget-browser-evidence.json. Начальный прогон завершился timeout на белой странице; повтор с HTTP-status/JS diagnostics полностью прошел, отдельная причина начального сбоя не подтверждена. Временная локальная роль менеджера и dev environment восстановлены. +- Not checked: полная основная manager analytics/attention, новые административные UI, публикация, полный a11y audit и релиз; этот блок их не закрывает. Новый commit требует своего Linux CI; предыдущий a10af12 прошел 1549 PostgreSQL tests + 39 locking и общий CI. + +Requirement audit WID01–WID08: PASS. Review не выявил оставшихся blocking findings в этом блоке. Основная аналитика/attention/другие экспорты остаются в полном BE09 scope; вся feature и PR пока draft. + +## Следующий блок: аналитика решений по кейсам, API + +Current: services/case_analytics.py группирует PartnerProgramProject по точному текущему option системного case, считает уникальных зарегистрированных leader/Collaborator внутри каждого bucket, дает нулевые строки текущих options и without_case для отсутствующего/устаревшего значения. Это корректно для legacy, но не источник решений. Solution уже имеет snapshot_case/team_user_ids для сданного представления и prefetch answers/members для черновика. + +Target/contract before implementation: отдельный GET /programs/{program_id}/management/solution-cases/ возвращает только агрегаты решений своей программы. C00 1.8.0 добавляется перед runtime реализацией. Доступ организатора/существующего can_manage_program; legacy mode дает контролируемый 409 C00. Старые overview/cases и DTO не меняются. Новый DTO содержит program_id, configured, submission_applicable, items и without_case; счетчик называется solutions_total, project_id/projects_total отсутствуют. Неприменимые submitted/not_submitted неконкурсной программы равны null, сохранение не требует submit. География/материалы/общая сводка/attention не выводятся из этого endpoint и остаются следующими блоками. + +| AC | Предусловие и наблюдаемый результат | +| --- | --- | +| CAS01 | Только организатор своей программы/текущий admin может читать; expert, captain, outsider, anonymous, foreign program и legacy проверены напрямую | +| CAS02 | Каждое ProgramSolution учитывается ровно один раз; текущие options идут в прежнем порядке, включая zero; пустой/устаревший кейс попадает в without_case | +| CAS03 | Submitted читает кейс и принятый состав из snapshot; draft из собственных answers/members; изменение live данных не переписывает сданный источник | +| CAS04 | Уникальные зарегистрированные captain/accepted members внутри bucket; повторное историческое членство, pending, чужая программа и Project не увеличивают состав решения; регистрация уже защищена DB unique(user, program) | +| CAS05 | Для noncompetitive submission_applicable=false и оба submission counts null, реальное сохраненное решение учитывается без искусственной сдачи | +| CAS06 | GET не пишет, не раскрывает ответы/оценки/контакты, Cache-Control private/no-store; запросов ограниченное число, без роста на каждую работу | +| CAS07 | C00 schema/examples проверяются обеими сторонами; frontend port/repository/adapter/use case передают настоящие агрегаты, 0/null и C00 errors через реальный HTTP interceptor | + +План: контракт/примеры → scoped service/API → PostgreSQL/schema/legacy regression → frontend API layer/wire/types/build → review и обновление draft PR. Новый продуктовый UI в этот блок не входит; его browser приемка не заявляется. + +## Проверка и review аналитики кейсов 25.10.2026 + +Реализовано в services/solution_case_analytics.py и SolutionCaseAnalyticsView. Доступ и явный solution mode проверяются до чтения агрегатов; неизвестные query отклоняются. Snapshot case/team используются для submitted, живые ответы/принятые members для draft. Регистрации выбираются только этой программы, люди уникальны внутри bucket. Legacy services/DTO не переписаны. Noncompetitive учитывает сохраненную работу со submitted/not_submitted=null. + +| AC | Evidence | Verdict | +| --- | --- | --- | +| CAS01 | API tests manager/admin/participant/expert/outsider/anonymous/foreign/legacy и HTTP JWT двух форм/неконкурсной | PASS | +| CAS02 | tests current option order/zero, empty/obsolete/missing value и сумма buckets = ProgramSolution count | PASS | +| CAS03 | tests submitted snapshot после намеренно измененных live case/captain/members; draft case из своих answers | PASS | +| CAS04 | tests registered accepted/pending/unregistered, historical повторное членство, legacy Project и другая программа того же капитана | PASS | +| CAS05 | API test сохраненного draft и HTTP noncompetitive program=6: null submission counters, ни одного submit | PASS | +| CAS06 | API test не более 10 queries с тем же количеством после восьми дополнительных решений, нет INSERT/UPDATE/DELETE, exact schema и private/no-store | PASS | +| CAS07 | C00 88/36/80; прежние 85/35/72 структурно сохранены и обе копии byte-identical; 12 real HTTP interceptor/error/unsafe-ID frontend tests | PASS | + +Checked by tests: финальный PostgreSQL прогон 37 tests (9 новых solution cases, прежняя case analytics и solution widget) PASS. Первые две ошибки новой fixture касались существующего DB unique регистрации и не настроенного второго option; fixture исправлена, runtime assertions сохранены. Black/Flake8 измененных Python и manage.py check PASS. Frontend полный npm run test:ci — 2627 tests / 474 files и strict C00 types PASS; Angular development build и scoped ESLint PASS. Locked Prettier 3.8.3: 1963 LF-normalized files без нарушений; итоговый git diff --check PASS. + +Checked through running API: настоящий Django/PostgreSQL/JWT, КЧ program=3 и hackathon=4 с одной работой и принятым зарегистрированным составом 2/1, noncompetitive=6 с одной сохраненной работой и составом 2. Ответ соответствует C00; participant/expert 403, anonymous 401, private/no-store; 0 изменяющих HTTP. Первый запрос пришел до завершения старта локального сервера, повтор после Listening прошел; сервер не перезапускался повторно. Пользовательские/production данные не использовались. + +Checked by source/review: источник строго program-scoped, настоящий ID программы, нет Project полей/ID, контактов, оценок и сырого массива ответов. UI не менялся; browser приемка нового экрана неприменима. Полный нагрузочный benchmark больших программ не выполнялся; тест гарантирует число запросов, не постоянный объем памяти. Blocking findings блока не обнаружены. Общая manager analytics/attention/материалы/прочие выгрузки, подключение frontend экрана, публикация и первый выпуск по-прежнему не завершены. + +## Основная manager analytics: требования до реализации + +Current sources: analytics.py считает Project links/ProjectScore/ProjectExpertAssignment, case analytics уже имеет отдельный solution API. Participant selectors уже выбирают принятый состав решения; definitions посещения/onboarding/региона и 30-дневного окна активности существуют. assignment_analytics.py задает waiting_since=max(assigned_at, submitted_at), pending/not_ready/completed, пять дней до дедлайна и 24/48h attention. evaluation_math.complete_values — общая проверка всех критериев, включая 0/False/пустую запись комментария. program_report.Report защищает XLSX строки от формул и сохраняет числа. Обычная форма решения не имеет отдельного Project.region; география участников остается по существующему user.city, география проектов не подменяется географией решений. + +Before/after API: C00 1.9.0 до runtime реализации добавляет GET management/solution-analytics/, assignments/, assignments/{assignment_id}/, solutions/ и export/. Новые агрегаты/страницы/книга содержат SolutionRef и solution IDs. Старые overview/attention/Project DTO не меняются. Доступ can_manage_program, явный solution mode, C00 errors и private/no-store. People attention продолжает использовать существующие scoped selectors; новые solution списки покрывают case drilldown, not_submitted и awaiting_evaluation. Отправка напоминаний/разрушающие команды и новый UI остаются отдельными блоками. + +Target: сводка, воронки, оценивание/просроченные эксперты, активность, кейсы и покрытие полей читают ProgramSolution. Distributed завершенность требует реального назначения и complete_values; open сохраняет первую оценку. Неприменимые noncompetitive submission/evaluation и open assignment aggregates равны null. Один solution учитывается один раз. Покрытие поля — описательная статистика присутствия ответа через empty_value (0/False являются ответами), а не новая проверка готовности/доступности файла. Applicable/required_applicable берутся из формы каждого решения (snapshot submitted/current draft); новое required поле не становится пропуском сданной работы. Для отображения метаданных stable field ID используется текущая definition, а для удаленной — первая сохраненная definition; содержимое сданного решения от этого не меняется. + +| AC | Предусловие и наблюдаемый результат | +| --- | --- | +| ANA01 | Прямые вызовы всех пяти API защищены manager/admin, программой и mode; participant/expert/outsider/anonymous/foreign IDs не раскрывают данные | +| ANA02 | Summary/funnel считают реальные solutions и уникальный registered accepted состав; Project и pending не увеличивают их; zero/empty не превращаются в ошибку | +| ANA03 | Distributed completed/partial/awaiting используют реальные назначения/required_evaluators и общую complete_values; own 0/False, blank comment row, no criteria, другой эксперт/программа проверены | +| ANA04 | Open сохраняет первую оценку и null assignment aggregates; noncompetitive дает null submission/evaluation aggregates и учитывает сохраненные drafts | +| ANA05 | Waiting/delayed experts сохраняют max двух времен, отсутствующий timestamp, 24/48h, порог пяти дней и deadline severity; GET не меняет статусы | +| ANA06 | Поля/материалы учитываются только там, где применимы; submitted snapshot неизменен, новое required поле затрагивает draft; 0/False answered, пустое необязательное не становится missing required | +| ANA07 | Activity использует 30 дней и настоящие submitted_at; case aggregates совпадают с C00 1.8; отсутствующий Project.region не выдается как вымышленная география решения | +| ANA08 | Scoped solution list сохраняет search/case/scope/submission/pagination, реальные snapshot names и safe captain поля; count соответствует выборке, noncompetitive attention неприменим | +| ANA09 | Manager assignments/detail используют настоящие SolutionExpertAssignment и Expert.user; scope all/completed/pending, score-row presence и revision-free readonly detail не подменяют IDs | +| ANA10 | XLSX program/solutions/submitted/scores содержит настоящие IDs, snapshot fields/answers и ту же сводку; строки защищены от формул, числа/0/False/partial scores не теряются | +| ANA11 | Число запросов ограничено и не растет на каждую работу, GET не пишет, legacy regressions проходят; large-volume benchmark отдельно не заявляется | +| ANA12 | C00/независимые сценарии синхронизированы; frontend API layer передает реальные DTO/0/null/query/Blob/error, строгие типы и build проходят; новый UI не заявляется готовым | + +План: контракт и независимые примеры → общий scoped read context и API → книга на существующем безопасном writer → PostgreSQL/HTTP/schema/legacy tests → frontend API/wire/types/build → requirement audit/review → draft PR. Подключение существующего экрана и browser acceptance следуют после готовности всех нужных данных, без фиктивной проекции Project. + +## Проверка и review основной manager analytics + +Реализованы SolutionAnalyticsContext, пять scoped readonly/export API и solution_program_report на существующем Report writer. C00 1.9.0 зафиксирован до реализации: 110 schemas / 41 operations / 113 scenarios; прежние 88/36/80 структурно сохранены, обе копии byte-identical. Старые Project overview, assignment, attention и report services не переписывались. Shared SolutionAnalyticsAPIView сохраняет существующий C00 1.8 endpoint кейсов, добавляя только общие проверки доступа/private cache и преобразование datetime в wire strings. + +| AC | Evidence | Verdict | +| --- | --- | --- | +| ANA01 | direct API matrix всех пяти endpoints: manager/admin, captain/expert/outsider/anonymous, legacy; чужие assignment/solution IDs и ID за safe range дают 404 без выдачи данных | PASS | +| ANA02 | API summary/funnel/attention tests accepted/pending/Project/другая программа, zero/empty; HTTP КЧ/хакатона/noncompetitive | PASS | +| ANA03 | actual rate 0/False/blank comment row, второй эксперт/квота, unassigned/invalid scores/no criteria tests; общий complete_values | PASS | +| ANA04 | open первая оценка, null aggregates, сохраненный noncompetitive draft и неприменимый attention API tests/HTTP | PASS | +| ANA05 | tests max assigned/submitted, 25h/24/48h, пять дней до deadline/warning/critical, not_ready null waiting; missing historical stamp проверен in-memory без снятия DB constraint нового submitted | PASS | +| ANA06 | новая required definition применима только draft; submitted 0/False answered, необязательная file не считается missing required; applied snapshot/current field metadata | PASS | +| ANA07 | 30-дневное окно/zero days/реальный submitted_at tests, cases parity с 1.8, Project.region не попадает в summary | PASS | +| ANA08 | count/page/search/case/submission/scope и snapshot name tests, invalid query/offset и waiting reason; фактический HTTP трех программ | PASS | +| ANA09 | реальные SolutionExpertAssignment + Expert.user, scope/status/score-row presence tests; targeted detail после review загружает только solution своего scoped assignment | PASS | +| ANA10 | настоящие XLSX program/solutions/submitted/scores, IDs/summary/snapshot answers/0/False/partial scores/no formulas; historical registration reading regression | PASS | +| ANA11 | не более 30 queries для summary/list/assignments, одинаковое число после восьми работ, нет business writes; 114 PostgreSQL tests затронутой/legacy аналитики и отчетов PASS | PASS | +| ANA12 | 47 новых HTTP wire tests и 5 прежних export tests PASS; полный frontend 2674 tests / 475 files и strict C00 types PASS, development build и scoped ESLint PASS | PASS | + +Checked by tests: широкий PostgreSQL прогон 114 tests PASS, после review scoped detail/score author query повторно 15 новых tests PASS. Первая новая fixture пыталась удалить уже заблокированные критерии после оценки; no-criteria выделен в допустимое исходное состояние, защита Criteria сохранена. Black/Flake8, makemigrations --check --dry-run, manage.py check и C00 PASS. Полный frontend/сборка указаны отдельно выше. Locked Prettier 3.8.3: 1965 LF-normalized files PASS; git diff --check обоих repos PASS. + +Checked through running HTTP API: реальный Django/PostgreSQL/JWT на вымышленных КЧ=3, хакатоне=4 и noncompetitive=6. Summary/cases parity, принятый зарегистрированный состав 2/1/2, actual solution list и assignment/detail, нулевой остаток завершенных оценок и noncompetitive null, participant/expert 403 и anonymous 401; все четыре книги читаются openpyxl, summary и ID согласованы, formula cells отсутствуют. Книга программы имеет 14 листов, solutions/submitted — 4, scores — 2. Evidence manager-analytics-http-evidence.json и manager-analytics-program-*.xlsx в design-artifacts/program-solutions-20261024/expert-v1/implementation. 0 изменяющих HTTP, реальные сообщения и production не использовались. + +Checked by source/review: новые DTO/книга используют SolutionRef и solution_id; общий расчет завершенности/просрочек и безопасный writer сохранены. На review устранены загрузка всех решений для одной assignment detail, unsafe integer до SQL и повторное чтение пользователя автора оценки (имя выбирается вместе с исходной записью). В старом поле Criteria и прежней книге поведение не изменялось. Новых blocking findings этого API-блока не осталось. + +Not checked: новый frontend экран и его browser acceptance, reminder confirmation/delivery, destructive management actions, оставшаяся публикация/QA01/REL01. Этот блок — полный набор указанных пяти read/export API, не готовность всей BE09/FE08. Large-volume memory/load benchmark и пересчет книги внутри Excel не выполнялись. Default legacy_project и реальные программы не переключались. + +Предыдущий опубликованный f75da24 прошел Linux PostgreSQL CI 40146313783: 1573 full-suite tests и 39 locking tests PASS; общий CI 40146310069 на момент следующего checkpoint еще выполнялся. Новый commit основной аналитики требует собственного CI, результат предыдущего не подменяет его проверку. + +## Напоминания участникам без команды + +Current behavior: существующий POST `/programs/{pk}/manager-overview/attention/participants-without-team/{reminder-preview|reminders}/` уже выбирает в solution mode капитанов/принятых участников по ProgramSolution/ProgramSolutionMember. Ожидающее приглашение не создает членство. Однако общий текст предлагает создать проект. + +Target behavior: только в solution mode текст предлагает «Создайте решение или присоединитесь к команде». Это касается КЧ, хакатона и неконкурсной программы. Legacy mode сохраняет «Создайте проект». Ссылка остается текущим `/office/program/{pk}/members`. Категория participants-without-visit сохраняет прежний текст и адрес программы. + +Контракт before/after: endpoint/method, request selection/search/token, response preview/token/counts/recipients, ошибки и manager/admin permissions не меняются. Меняется только значение preview_text и соответствующий текст сохраненного уведомления/письма. C00 1.9.0 не расширяется: это исправление существующего участнического flow. Миграция не нужна. Angular consumer продолжает показывать server preview_text; новый UI D02 ожидает отдельного согласования. + +План: mode-aware текст общего message_for → PostgreSQL tests реального preview/confirm/outbox/delivery на locmem email → regression существующих attention actions и membership → review. Реальные письма не отправляются. Напоминания экспертам о решениях, новая категория решений и destructive commands не входят в этот локальный блок и остаются впереди. + +| AC | Проверяемое поведение | +| --- | --- | +| ATT01 | Preview solution-программы адресован только зарегистрированным пользователям без принятой команды этой программы; pending приглашение и исторический Project не подменяют членство | +| ATT02 | КЧ/хакатон/noncompetitive используют «Создайте решение», legacy — «Создайте проект»; пути, структура ответа и visits-сценарий сохранены | +| ATT03 | Confirm повторно проверяет состав, создает outbox после валидного preview; повтор token не создает вторую рассылку, очередь запускается после commit | +| ATT04 | Вступивший в команду после preview исключается на confirm, вступивший после confirm — при delivery; письмо повторно не отправляется | +| ATT05 | Локальная доставка содержит solution-текст и рабочий программный путь; реальные SMTP/Celery не используются в проверке | +| ATT06 | Прямой API сохраняет manager/admin program scope: участник, посторонний и аноним не получают preview/confirm; неверная программа не раскрывает данные | + +### Verification и review участнических напоминаний + +| AC | Evidence | Verdict | +| --- | --- | --- | +| ATT01 | Реальный preview API: капитан/принятый участник исключены, pending и участник с историческим Project включены, чужая программа исключена; preview не создает outbox | PASS | +| ATT02 | API matrix case_championship/hackathon/noncompetitive/legacy; visits сохраняет текст и программный путь | PASS | +| ATT03 | Confirm API и captureOnCommitCallbacks: сохраненные source user IDs/text/path, один durable notice и один enqueue при повторе token | PASS | +| ATT04 | Принятый member после preview дает skipped=1 на confirm; капитан нового решения после confirm отменяет delivery с attempts=0 | PASS | +| ATT05 | Locmem delivery: solution-текст, существующий /members URL, один приватный адресат и одно письмо при двух вызовах; sent/attempts/emailed_at сохранены | PASS | +| ATT06 | Реальные прямые API checks anonymous=401, participant/outsider/wrong-program=403, staff=200; outbox без прав не создан | PASS | + +Checked by tests: 8 новых PostgreSQL tests PASS; общий повтор с существующими attention actions/analytics — **107 tests PASS**, включая эти восемь. Команда: `python procollab-test-unicode.py partner_programs.tests.test_solution_participant_reminders partner_programs.tests.test_attention_actions partner_programs.tests.test_attention_analytics_api`; локальный PostgreSQL 18, порт 55439, ICU test DB. Log: `C:/Users/evsee/AppData/Local/Temp/procollab-solution-participant-reminders-regression.log`. Black/Flake8 только измененных Python-файлов и git diff --check PASS. + +Checked by source/review: endpoint → managed_program → selection/preview → подписанный token → confirm lock/recheck → on_commit/outbox → delivery recheck прослежен; код изменяет только mode-aware текст message_for. Angular analytics-drilldown показывает server previewText, response/payload/permission/path не менялись. Blocking findings этого локального блока не обнаружены. Build неприменим к изменению текста Python; новая схема или migration не требуется. + +Not checked: новый manager Angular UI и его delivery interaction, внешний SMTP/Celery, production. Новые напоминания экспертам о решениях и остальные BE09/FE08 остаются незавершенными; этот результат не означает готовность всей feature или прохождение нового Linux CI. + +## Напоминания экспертам о решениях + +Current behavior: прежний work-reminder получает PartnerProgramProject/ProjectExpertAssignment и ведет на projects-rating. Его нельзя использовать, подставив solution_id в project_id. Существующие manager solution list/assignments уже определяют ожидание через общие критерии и реальные SolutionExpertAssignment. + +Target behavior: организатор выбирает ожидающие решения, получает read-only предпросмотр получателей и подтверждает его подписанным token. Одно письмо на реального активного эксперта программы включает только выбранные решения с его настоящими незавершенными назначениями. Название берется из снимка сдачи, ссылка ведет на существующий `/office/program/{pk}/solutions-rating`. Неназначенная работа не создает получателя. 0/False и допустимый пустой текст учитываются общей функцией complete_values, строка оценки сама по себе не означает завершенность. + +### Contract before и after + +До изменения в C00 1.9.0 нет solution expert reminder command. В 1.10.0 добавляются два POST под `/programs/{program_id}/management/solution-analytics/attention/solutions-awaiting-evaluation/`: `reminder-preview/` и `reminders/`. Прежний manager-overview attention endpoint и его project category остаются прежними. + +Preview request: `search` до 200 символов (default пустая строка) и `selection` с `mode=selected|all`, `ids`, `excluded_ids`. В ids используются настоящие **solution IDs**; selected требует непустой ids без exclusions, all допускает только exclusions. Каждый ID — положительный safe integer, не более 10000 элементов. Неизвестные поля и query params отклоняются. Поиск повторяет snapshot name; решения ограничены программой, submitted и теми же awaiting/partially_evaluated состояниями, что manager analytics. Выбор не ограничивается видимой страницей, максимум 10000 найденных строк. + +Preview response сохраняет знакомую форму: `token` (nullable), `selected_count`, `recipient_count`, `without_assignments_count`, `unavailable_count`, `preview_text`, первые 20 `recipients={user_id,full_name}`, `expires_in=900`. Email не раскрывается. Token подписан для actor/program/category, содержит исходные solution IDs и утвержденные настоящие assignment IDs. Если получателей нет, token null. + +Confirm принимает только token и возвращает `created_count/skipped_count`. Существующие outbox/idempotency/on_commit/retry сохраняются. Перед confirm и delivery актуальный набор пересекается с утвержденными source/assignment IDs: новые назначения/получатели не добавляются молча. Полностью оцененные работы, выполненные/снятые назначения, ушедшие или неактивные эксперты исключаются. Повтор token возвращает исходный receipt. Delivery нового типа блокирует программу, как операции оценивания, прежде чем повторно читать состояние. + +Доступ: manager/admin конкретной программы, solution mode + competitive + distributed. Ошибки C00: 400 validation/token, 401 authentication, 403 program permission, 404 not found, 409 solution_mode_unavailable/reminders_not_applicable. Все ответы private, no-store. При отключении режима после подтверждения доставка отменяется. Дедлайны и правила завершенности не переопределяются; это перенос существующего reminder flow на новый источник. + +Affected systems: C00 обеих репозиториев и generated frontend DTO/fixtures; новый API view; общий attention outbox с отдельной category; notification feed уже читает общий reminder. Миграций и реальных отправок не требуется. Новый Angular экран D02 остается до явного approval. Порядок: C00/spec → backend selectors/preview/confirm/delivery → API tests/legacy regressions → frontend API layer → review/draft PR. + +| AC | Проверяемое поведение | +| --- | --- | +| REM01 | Preview manager/admin закрыт от остальных ролей/чужих программ, legacy/open/noncompetitive контролируемо отклоняются; private headers и C00 errors | +| REM02 | Выбираются только реальные submitted awaiting/partial решения текущей программы, snapshot search/order, selected/all/exclusions, safe ID/limit/strict validation | +| REM03 | Только реальные незавершенные SolutionExpertAssignment активных экспертов программы; один получатель на user, неназначенные и missing email считаются отдельно | +| REM04 | Полная оценка 0/False/comment не получает напоминание, partial/invalid score остается незавершенным; quota и no-criteria совпадают с manager analytics | +| REM05 | Подписанный preview привязан к actor/program/category/TTL; подмена, истечение и чужой token не создают outbox | +| REM06 | Confirm recheck/intersection, after-commit enqueue, rollback и повтор token не добавляют новых recipients/assignments или повторных уведомлений | +| REM07 | Delivery recheck отменяет completed/removed/inactive/mode changes, сохраняет только утвержденные назначения; locmem письмо с snapshot names и solutions-rating URL, один email при retry | +| REM08 | Никаких Project IDs/проектных назначений в новом flow; mixed/colliding IDs и прежние участнические/project reminders сохраняют семантику | +| REM09 | C00 и generated frontend types/fixtures синхронизированы; API adapter передает selection/token без фиктивных Project DTO; новый UI не заявляется реализованным | + +### Verification и review экспертных напоминаний + +| AC | Evidence | Verdict | +| --- | --- | --- | +| REM01 | Direct API tests manager/staff/captain/expert/outsider/anonymous, foreign/missing program, legacy/open/noncompetitive; 24 реальных HTTP/JWT checks обеих форм/noncompetitive с private headers | PASS | +| REM02 | Snapshot/live-name mismatch, selected/all/exclusions, 25 работ за пределами первой страницы, unknown keys/query, unsafe IDs и max found; независимая schema кодирует ограничения mode | PASS | +| REM03 | Один эксперт на несколько работ, 21 получатель с preview первых 20, unassigned/missing email/inactive и foreign solution; signed source/assignment IDs соответствуют реальным моделям | PASS | +| REM04 | Общая complete_values: 0/False/empty comment completed; partial/NaN/no criteria и quota 2→1 сохраняют семантику manager analytics | PASS | +| REM05 | Signature tampering, TTL, actor/program/category scope отклоняются без outbox | PASS | +| REM06 | Confirm recheck после completion/replacement/new expert, durable receipt/idempotency/on_commit/rollback; два потока PostgreSQL синхронизированы Barrier и создают один batch/notice/enqueue | PASS | +| REM07 | Locmem delivery удаляет из письма замененное назначение, отменяет completed/left-program/open-mode, сохраняет solution route и snapshot names; два вызова дают одно письмо | PASS | +| REM08 | Исторический Project с тем же ID и прежним проектным назначением другому эксперту не попадает в новый flow; platform notice/read принадлежит назначенному эксперту; старые attention regression tests PASS | PASS | +| REM09 | C00 1.10.0 116/43/128 и обе копии byte-identical; 21 новых frontend wire tests + 47 analytics PASS, полный frontend 2695 tests / 476 files и strict types PASS; development build и scoped ESLint PASS | PASS | + +Checked by tests: **22 новых PostgreSQL tests**, вместе с прежними участническими/проектными напоминаниями, attention analytics и manager solution analytics — **144 tests PASS**, включая эти 22. Локальный PostgreSQL 18/ICU DB, helper `C:/Users/evsee/AppData/Local/Temp/procollab-test-unicode.py`; log `C:/Users/evsee/AppData/Local/Temp/procollab-solution-reminders-regression.log`. Black/Flake8, C00 validator и makemigrations --check PASS. Новых migrations нет. + +Checked through running API: локальный Django/PostgreSQL/JWT, 24 POST проверки preview/отклоненного confirm для manager/expert/participant/anonymous в КЧ, хакатоне и неконкурсной программе. Здесь конкурсные работы уже полностью оценены: проверены empty recipient preview/token null, invalid-token и role/mode errors. Domain mutations=0; успешные confirm и доставка с pending recipients проверены отдельными PostgreSQL/locmem tests, а не этим HTTP запуском. Evidence: `design-artifacts/program-solutions-20261024/expert-v1/implementation/solution-expert-reminder-http-evidence.json`. + +Checked by source/review: требование → новые C00 operations → manager/mode guards → shared selection/token/outbox → delivery прослежены. Completion и quota не дублируются; SolutionAnalyticsContext и complete_values используются повторно. Legacy CATEGORIES и старые routes/DTO не расширялись. Generated frontend port/repository/adapter/use cases принимают настоящие solution IDs и непрозрачный token. На review уточнена условная schema selection, явно синхронизирован concurrent test; незакрытых blocking findings этого блока не осталось. + +Checked by build: Angular development build PASS с существующими Sass deprecation warnings. Новый manager Angular UI не менялся, D02 **DESIGN APPROVAL REQUIRED**. Not checked: реальный UI reminder interaction, внешний SMTP/Celery, large-volume memory/load benchmark, production. Собственный Linux CI нового commit еще требуется. Предыдущий a66dc73 отдельно прошел PostgreSQL CI 40150230796: **1596 full-suite + 39 locking tests PASS**, это не результат нового diff. Общая feature/BE09/FE08 и QA01/REL01 остаются незавершенными. + +### Исправление совместимости со Swagger после полного CI + +Полный PostgreSQL CI 40151829616 commit 64451c5 запустил 1618 tests и завершился одной ошибкой `test_swagger_schema_is_available_for_staff`: служебное имя `action` в новом APIView заставляло drf_yasg трактовать его как ViewSet и читать отсутствующий action_map. Это реальная регрессия глобальной схемы, предыдущие targeted проверки ее не покрывали. Внутренний атрибут/параметр as_view переименован в reminder_action. Endpoint, payload, C00 1.10.0 и права не меняются, миграций для этого исправления нет. + +Проверка существующего staff Swagger regression test и всех 22 экспертных reminder tests PASS в совместном локальном PostgreSQL запуске. Source trace drf_yasg.get_operation_keys подтверждает устранение ветки ViewSet; Black/Flake8 и diff проверяются перед отдельным fix commit. Новый Linux CI обязателен; failed run 64451c5 не считается успешной полной проверкой. Дальнейшая неконкурсная публикация идет отдельным незавершенным блоком и не входит в этот fix. diff --git a/docs/product/program-solutions-competitive-publication.md b/docs/product/program-solutions-competitive-publication.md new file mode 100644 index 00000000..e56b0d10 --- /dev/null +++ b/docs/product/program-solutions-competitive-publication.md @@ -0,0 +1,51 @@ + + +# BE08: публикация сданных конкурсных решений + +Основание — подтвержденная публикация материалов в разделе «Решения» самой программы после ее завершения при включенном организатором флаге. Вопрос о несданных конкурсных черновиках уже задан и остается открытым. Этот блок реализует независимый путь **уже сданных** КЧ/хакатон работ; не определяет окончательную eligibility несданных работ и не завершает весь BE08/QA01/REL01. + +До изменения publisher и публичные readers целиком исключают competitive программы. После изменения publisher под program lock устанавливает published_at только активным сданным решениям завершенной solution-программы с включенным publish_projects_after_finish. Неконкурсная ветка сохраняет текущие сохраненные материалы без искусственной сдачи. Старый Project publisher и legacy маршруты сохраняются, Project не создается и не удаляется. Архивные работы по-прежнему исключаются. + +Публичные GET `/programs/{program_id}/published-solutions/` и `.../{solution_id}/` сохраняют DTO/allowlist/query/ошибки/no-store. Конкурсный список становится 200, включая пустой список; опубликованные сданные решения доступны анонимно. Конкурсные draft не выдаются даже с некорректно установленным marker. Материалы, название, форма и принятый состав читаются из действующего snapshot сдачи; новые поля и живые черновые значения его не подменяют. Административная смена кейса/состава обновляет действующий снимок по уже реализованному правилу, исходная ревизия остается в приватной истории. Закрытые оценки, назначения, анкеты и pending не публикуются. + +Published_at остается readonly, идемпотентным и сохраняется после выключения флага/переноса даты окончания. GET не запускает публикацию. Сдача сама по себе не публикует работу: marker ставит прежний job. Создание новых заявок можно выключить независимо. Новых столбцов, миграций, операций, полей DTO или публичных прав на запись нет; существующие C00 public GET применяются к уже сданному конкурсному материалу. C00 получает новое описание и сценарии; временный пример 404 всего competitive списка заменяется на 200 пустого списка, остальные прежние схемы/операции/примеры сохраняются. + +| AC | Проверка | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| CPUB01 | Завершенные КЧ/хакатон solution программы с флагом публикуют submitted, не draft/архив; без флага/до даты marker не ставится | +| CPUB02 | Публичные list/detail/search/pagination используют snapshot и принятый состав, сохраняют 0/False/file; новые живые поля/ответы не подменяют сдачу | +| CPUB03 | Pending/анкеты/email/оценки/назначения/приватная история не раскрываются; private endpoints сохраняют доступ | +| CPUB04 | Draft с marker, чужая/неопубликованная/архивная работа скрыты 404 для всех ролей; competitive список дает 200 без утечки этих работ | +| CPUB05 | Первая дата публикации сохраняется при повторах, выключении флага/creation и переносе даты; GET и первая сдача не ставят marker сами | +| CPUB06 | Административные case/member revisions читаются как действующий snapshot, исходный снимок остается в приватной истории; архив отзывает выдачу с сохранением данных | +| CPUB07 | Program locks сериализуют publisher с archive/другим publisher; наблюдаемое ожидание PostgreSQL проверено до снятия блокировки | +| CPUB08 | Legacy Project, неконкурсные материалы, прежние данные и C00 consumers сохраняются; новые anonymous HTTP/C00 checks и регрессия подтверждают границы | + +Порядок: контракт/AC → воспроизводящие тесты → publisher/readers → PostgreSQL/static/contract → frontend shared contract и wire compatibility → фактический локальный HTTP/retention → review/fixes → draft PR. Нового Angular UI/layout нет: D02/D03 approval остаются открытыми. Реальные программы не переключаются, миграция BE10 не запускается, merge/deploy не выполняются. + +## Результат и requirement audit + +Реализованы существующие publisher и два public readers, без новых операций/полей/миграций. Публикация конкурсного списка расширена с временного404 на200; noncompetitive/legacy правила и приватные маршруты сохраняются. Публичный queryset повторно отбирает submitted, поэтому одного ошибочного marker у draft недостаточно. Publisher и административные writers используют прежний program lock. + +| AC | Evidence | Verdict | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | +| CPUB01 | Native submitted/draft/flag/date boundary и actual49/50 КЧ/хакатон: publisher0 до срока/без флага, затем1;51 noncompetitive публикует2 saved без submit | PASS | +| CPUB02 | Native snapshot/live-name/answers/labels/новое обязательное поле и submitted-only pagination; actual0/False/file URL/accepted2 и поиск по snapshot | PASS | +| CPUB03 | Native query capture не читает project_rates; реальные private score0/False/comments не входят в public payload, pending/email/анкеты/permissions отсутствуют; private GET401/404 | PASS | +| CPUB04 | Native marked draft по4 ролям, чужая программа и пагинация; actual draft по5 ролям/unsafe/архив404, invalid query400 и bad JWT401 | PASS | +| CPUB05 | Native boundary/repeat/stale flag и readonly GET; actual repeat0, publication flagFalse и будущая end date не снимают marker, creationFalse не мешает reading/actions | PASS | +| CPUB06 | Actual signed case/member → current public snapshot, original snapshot в history; archive снимает public, сохраняя marker/сдачу/4 values и файлы | PASS | +| CPUB07 | 3 TransactionTestCase наблюдают pg_blocking_pids до unlock: publisher ждет archive; archive ждет publisher; повторный publisher ждет и сохраняет первую дату | PASS | +| CPUB08 | Final432 PostgreSQL/OK без skips, отдельно4 legacy publishing/OK;2955 FE/484 files/types/build/scoped lint;97 C00 HTTP/34 bodies;25 прежних моделей совпали до/после, Project4→4 | PASS в CPUB scope | + +Checked by source: publisher→program lock→active submitted selection; public GET→mode/marker/status→snapshot allowlist; существующие Angular port/adapter/repository/use cases принимают тот же DTO. Checked by tests: исходный red3/3 FAIL, промежуточный30/2 fixture errors (недобавленный вариант кейса и текст в int criterion) сохранен; после корректных fixtures30 PASS, широкий431 PASS, затем усиленные assertions нового поля/пагинации вошли в final432 PASS. Product validations и guards не ослаблялись, данные ради PASS не сбрасывались. + +Checked by build/static: strict C00 types, Angular development build (прежние Sass warnings), scoped ESLint, Black/Flake8/Django check/migration consistency/C00 PASS. Shared3 файла byte-identical;156 схем неизменны,59 операций изменились только описаниями2 public GET;298 прежних scenarios неизменны, один временный404 намеренно заменен200 и добавлены6 новых (305 total). Generated types отличаются только version comment, fixtures отражают примеры. + +Checked through running API:97 немокированных HTTP ответов/34 валидных тела на собственном localhost8877 API, новые49/50/51 и66–71. Publisher запускался только для этих трех program IDs с проверенным prefix, глобальный job main DB не вызывался. Старые25 моделей, включая Auth User/Project/историю/аудит C2, SHA54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169 совпал повторно после всех checks;7 прежних registration audit headers сохранены. Флаги нового создания всех трех программFalse. Owned wrapper10408/child39748 остановлены,8877 освобожден; прежние8769/8871/8876/55439 не менялись. + +Not checked: новый Angular public UI (D03 pending), CDN bytes (использованы собственные metadata fixtures), production/реальная доставка и окончательная eligibility несданных конкурсных черновиков. Новый публичный дизайн не предполагается согласованным. Полный AC18/19, QA01/REL01 и первый выпуск остаются OPEN; BE10 отдельно. Это реализация и проверка CPUB, не READY TO MERGE всей feature. + +Review: requirement audit выше выполнен от исходного scope и diff; actionable BLOCKER/MAJOR/MINOR в CPUB scope не осталось. Риски ограничены intended доступом по marker и200 пустой страницы вместо временного404; старый reader конкурсных решений при rollback скроет опубликованные материалы, поэтому сохраняется C00 1.21 reader/publisher. Схема не откатывается, новые миграции не нужны. Merge/deploy/реальных switches не было. + +Отдельный **MAJOR integration finding** полного выпуска: после fetch origin/dev backend target587a9155 содержит новую0031_programcertificate_rendered_batch (зависит от общей0030), а feature заканчивается0038_solution_registration_removal_audit. Текущий checkout graph чист; read-only overlay новой dev migration через MigrationLoader(None) подтверждает две leaf nodes. Git merge-tree preview не имеет текстовых конфликтов, но не решает граф Django. Требуется отдельная сверка актуальных certificate models/migrations и безопасное объединение графа без перенумерации/удаления старых миграций и потери данных. Frontend dev08414b79 содержит новые certificate changes. Branch merge/rebase/force-push не выполнялись; проверки432/2955 относятся к указанным feature product refs, а не еще не подготовленной интеграции с последним dev. Поэтому готовность всей feature к merge не заявляется. diff --git a/docs/product/program-solutions-dev-compatibility.md b/docs/product/program-solutions-dev-compatibility.md new file mode 100644 index 00000000..982935df --- /dev/null +++ b/docs/product/program-solutions-dev-compatibility.md @@ -0,0 +1,32 @@ +# Совместимость решений с актуальным dev: граф миграций + +CI40223628921 на b66cf6cb остановился на Check migration consistency: dev добавил `0031_programcertificate_rendered_batch`, а feature продолжает ветку `0031_solution_field_options` → `0038_solution_registration_removal_audit` от общей `0030_program_solution_foundation`. Git merge-tree не имеет текстовых конфликтов, но Django обнаруживает две leaf nodes. Это подтвержденный integration defect полного выпуска, не ошибка CPUB queryset. + +Минимальное исправление: взять без изменений уже существующие в observed dev587a9155 nullable `ProgramCertificate.rendered_batch` и его migration0031, затем добавить пустую merge migration0039 с зависимостями от обеих веток. Исторические migration names/operations не менять, старые таблицы/решения/Project/материалы/оценки/историю не удалять. Certificate services/UI не переписываются; их изменения последнего dev проверяются в отдельном prospective integration snapshot. Код/PR ветки не сливаются и не переписывают историю. + +В CPUB контракте ничего не меняется: C00 1.21.0/156 schemas/59 operations/305 scenarios, решения продолжают отдельную модель. Порядок будущего rollout: совместимые models/migration graph → additive upstream0031 и empty0039 → backend/workers → frontend. Schema-down после записанной истории недопустим, creation-only rollback сохраняется. Реальные программы не переключаются; rehearsal допустим только в guarded localhost PostgreSQL CI окружении. + +| AC | Требуемое доказательство | +| --- | --- | +| INTEG01 | Один leaf и detect_conflicts={} в текущем/prospective графе; исходная dev0031 byte-exact после нормализации line endings, merge0039 без operations | +| INTEG02 | Solution-first0038 →0039 сохраняет legacy Project, draft/submitted/архивные JSON, ответы/состав/оценки; certificate pdf bytes/metadata не меняются, новый FK backfill соответствует прежнему batch | +| INTEG03 | Certificate-first0031 →0039 сохраняет PDF bytes, download token и уже записанный rendered_batch; существующие base solutions/Project не теряются | +| INTEG04 | Текущие solution/certificate/legacy tests и prospective merged source migration checks подтверждают совместимость наблюдаемого dev; не подменять prospective proof текущим checkout | +| INTEG05 | Guarded local forward migration/recheck сохраняет все выбранные прежние rows/колонки и реальные CPUB/C2 snapshots/events; main не сбрасывается, старые миграции не откатываются | +| INTEG06 | C00/FE consumers и исходные26 AC не меняются; Linux CI конкретного нового HEAD проверяется отдельно, общий QA01/REL01/D02/D03 не закрывается автоматически | + +Проверка должна различать current feature refs, observed dev refs и prospective tree/source hashes. Снимок prospective кода нужен только для проверки: он не выполняет merge/rebase/force-push/deploy. Никаких новых продуктовых правил сертификатов не вводится. + +## Аудит реализации 2026-10-25 + +Скопированы ровно nullable FK и исходная dev0031; generated merge0039 не содержит операций. Три новых PostgreSQL теста прошли отдельно (48.751s), затем вместе со всей регрессией решений и четырьмя legacy publisher tests: **439/OK, 429.683s, без skips**. Тесты меняют схему только isolated test DB, проверяют оба порядка веток и восстанавливают последний граф. Значения JSON 0/False, архивы/история/оценки и PDF bytes/download token сохраняются; certificate-first сохраняет первоначальный rendered_batch, даже если текущий batch другой. + +Guarded localhost main rehearsal применил только две forward migrations. Все прежние колонки/строки **27 моделей** совпали до/после: SHA256 `74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6`. Main не откатывался/сбрасывался, реальные program flags не менялись. В main нет PDF rows; непустой certificate backfill подтвержден двумя isolated migration tests. Black/Flake8/Django check/makemigrations consistency прошли. + +Расширенная локальная попытка на459 tests не завершилась: native Windows access violation `0xC0000005` внутри WeasyPrint FontConfiguration. Отдельный вызов FontConfiguration без Django воспроизвел сбой, включая собственную временную конфигурацию с Mont fonts. Точная причина DLL не установлена. Renderer и assertions его тестов не менялись; 459 не объявляются PASS. Полный PDF/backend suite должен пройти в поддерживаемом Linux CI на конкретном новом HEAD. + +Старый general CI40223626180 завершился failure:1809 tests,3 failures,69 skips, Lint success. После Transport closed у connector полный failed log получен read-only через gh. Все три failures — PostgreSQL vendor assertion в новых CompetitivePublicationLockTests на SQLite. Добавлен такой же backend guard, как у существующих lock suites: только SQLite пропускает три pg_blocking_pids races; PostgreSQL продолжает исполнять их с прежними assertions. Data migration tests проверяют сохранность данных и на SQLite: снято только ненужное ограничение на vendor, assertions строк/PDF/графа сохранены. Focused SQLite:15 tests/51.926s/OK, ровно3 skips (lock tests), обе migration orders выполнены. Это отдельная среда, ее skips нельзя выдавать за PostgreSQL coverage. CI PostgreSQL40223628921 отдельно остановился на конфликте двух leaf nodes до тестов. + +INTEG01–03/05 подтверждены для текущего checkout; INTEG04/06 остаются частичными до проверки prospective исходников и Linux CI. Полный выпуск, D02/D03, исходные AC18/19 и отдельный BE10 не закрыты этим исправлением. + +После разделения окружений повторный focused PostgreSQL: **15/51.294s/OK без skips**, включая все три реальные pg_blocking_pids races и обе migration orders. Производственные models/services/migrations с прошедших439 не менялись; test-only поправка не заменяет полный Linux regression. Read-only prospective snapshot первоначального9c89367+dev587a9155 также прошел check/check models/makemigrations с единственным leaf0039; окончательный snapshot и hashes фиксируются в отдельном QA report. diff --git a/docs/product/program-solutions-qa01.md b/docs/product/program-solutions-qa01.md new file mode 100644 index 00000000..211ffb1c --- /dev/null +++ b/docs/product/program-solutions-qa01.md @@ -0,0 +1,110 @@ +# QA01 Матрица первого выпуска ProgramSolution + +Статус: **частичная сквозная приемка; общий QA01 не PASS и не READY TO MERGE**. Сводка 25.10.2026: AC01/02/03/08/11/12/23 имеют конкретный PASS по MR/FORM; AC06 — по ACCESS; AC04/05/07/09/24/25/26 — по TEAM/PART/INV/NEG с новым C001.16 role fix. AC10/13/14/15/16/17 подтверждены DRAFT: новая readonly JSON/XLSX/snapshot проверка,6 focused PostgreSQL races и audit исторических фаз. FORM refs — backend runtime30c0d3e/frontend37ecd71d/C001.14; CASE API и ACCESS — C001.15; TEAM role fix — C001.16, точные source hashes и этапы проверок в отчетах. Остальные общие verdict требуют отдельного сопоставления, прежние PART01/NEG/INV/ER/ROL сохраняют свои refs и ограничения. Источник требований — [каноническое ТЗ](https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175), неизменный блок baeab2fb-c1e1-46c0-9e95-2f5c4b9bfc41. Тексты всех26 AC сохранены без изменения. AC21 относится к отдельному BE10 после первого выпуска; QA01 требует AC01–AC20 и AC22–AC26. + +Предыдущий этап завершил API-фильтр/filtered XLSX и дал 244 PostgreSQL tests, 2754 frontend tests/478 files и 143 настоящих HTTP checks. Это доказательство соответствующего scope, а не сквозной приемки. Ни одна строка ниже не закрывается по одному имени test file или общему числу зеленых tests. Перед PASS нужно прочитать конкретные assertions, привязать runtime/source ref и выполнить нужный сценарий. + +## Матрица требований и следующего доказательства + +| AC | Исходное требование | Где искать существующее evidence | Что еще подтвердить для полного verdict | Статус QA01 | +| --- | --- | --- | --- | --- | +| AC01 | Создание решения новой программы не создает Project или самостоятельную Team | [FORM01](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md); ProgramSolution/team/current DB audit | Три реальные Angular create: решения42/43/44; Project count неизменен, standalone Team модели нет. Baseline снят после отдельного fixture setup, до проверяемых writes | PASS — FORM01 | +| AC02 | Повторное создание капитаном открывает существующую заявку; конкурентные запросы не создают дубль | [MR01–MR03](../qa/program-solutions-membership-races-20261025/README.md); прочитаны конкретные assertions foundation concurrency | На runtime 30c0d3e обе формы: два независимых API/наблюдаемые PostgreSQL wait paths → 201/200 с одним ID; повтор не меняет имя/version. Angular d2a2cd3f открывает прежний ID через «мое решение», creation=False, без solution POST | PASS — MR01–MR03, ограничения browser attempts в отчете | +| AC03 | Черновик с одним капитаном сохраняется без кейса и обязательных при сдаче материалов | [FORM02](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md); empty draft PATCH/readback | КЧ/хакатон/noncompetitive: один капитан при min_size=3, пустой кейс/материалы, сохранение имени дает PATCH200/draft; затем перечитаны ответы | PASS — FORM02 | +| AC04 | Состав команды и приглашения доступны внутри заявки; отдельный выбор команды не требуется | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); PART01 + TEAM source audit | Команда/pending во второй вкладке одной заявки; PART01 actual ID/readback, вкладки не теряют ввод. Team/modal/shared form/use cases совпадают с INV refs; отдельной Team нет. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC05 | Принятие, отклонение, отзыв и изменение приглашений повторяют действующие права и переходы | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); PART01 + INV + current API tests | Actual captain/recipient create/accept/decline/edit409/retry/revoke204, role-specific403/404, processed409/history и обе versions. Новая обязательная role не меняет эти переходы; historical null допускает прежние действия. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC06 | Посторонний, обычный участник и эксперт не могут редактировать ответы или сдавать за капитана | [ACCESS](https://github.com/PROCOLLAB-github/procollab/blob/79b4418d4bb891333b62c6025291af769d326b6e/docs/qa/program-solutions-access-20261025/README.md); test_solution_access_boundaries.py | КЧ/хакатон: member/expert PATCH и submit403, outsider404/anonymous401; draft скрыт от эксперта/постороннего.36 прямых HTTP+5 browser GET соответствуют C00;15 domain моделей неизменны. Настоящий logout/другой login дает404 без прежних данных; component сам очищает/cancels,28 route tests и42 backend answers/submission/access tests PASS. Baseline обычный logout уже снимал view контейнером, exposure не заявляется | PASS — ACCESS, refs/границы в отчете | +| AC07 | При максимуме команды учитываются ожидающие приглашения; при минимуме только принятые участники и капитан | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); INV01/02 + NEG minimum + team/submission assertions | Observed two-worker last-slot201/409, fresh-version retry409/team_max_size;1/1/2 → accept2/0/2. Pending-only минимум не выполняет, draft/team focus; после accept проверяются материалы. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC08 | Два одновременных вступления в разные команды активной программы дают не более одного успеха | [MR04–MR07](../qa/program-solutions-membership-races-20261025/README.md); прочитаны invitation TransactionTestCase/Barrier/assertions | Обе активные формы: один получатель, две команды, независимые API, оба PostgreSQL wait paths наблюдались до снятия lock → 200/409 already_in_program_team, ровно одно членство. Повтор проигравшего не меняет pending/version; обычный decline и UI вход/чужой404 подтверждены | PASS — MR04–MR07; не нагрузочный тест | +| AC09 | После сдачи нельзя изменить ответы или состав; отклонение и отзыв ожидающего приглашения доступны | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); PART01 frozenTail + current invitation/team/submission tests | После submit PATCH/create/accept/remove/leave/transfer запрещены; pending recipient decline/captain revoke204 сохраняют form/answers/accepted roster/snapshot. Privileged admin exceptions остаются отдельным открытым BE08. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC10 | Кейс не выбирается автоматически; недопустимый или отсутствующий обязательный выбор блокирует сдачу | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); PART/FORM/NEG01 + current submission assertions | Empty case не выбирается автоматически; missing400/case_required с field_id и retired400/case_unavailable сохраняют draft/snapshot=None. Browser NEG открывает тот же case control; source/readers совпадают с проверенным runtime. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC11 | Организатор настраивает поля, обязательность, порядок и ограничения; чужой организатор доступа не получает | [FORM03/07](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md); настоящий manager UI и scoped API | Явные подпись/подсказка/варианты, optional PDF1.5МиБ, number0–100/text20/checkbox и порядок; чужой manager GET/PUT403, forged101/text21 не меняют ответы | PASS — FORM03/07 | +| AC12 | Шаблоны КЧ и хакатона дают разные формы, а последующее изменение шаблона не меняет существующую программу | [FORM04](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md); persisted form_data и каталог | Разные формы КЧ/хакатона, field IDs не пересекаются. Изменение каталога только в verifier Python process не меняет сохраненные form_data; реальные template GET до/после совпали | PASS — FORM04; scope mock каталога указан | +| AC13 | Пустое необязательное поле, число 0 и ответ false корректно различаются | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); FORM05/PART + current7 JSON/3 XLSX GET + field-filter tests | КЧ/хакатон/noncompetitive: empty optional отсутствует в answers/answer rows, definition есть; int0 и bool false сохранены, XLSX types n/b. Actual UI save/reload/tabs/readonly подтвержден FORM/PART, core renderer/control source неизменен. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC14 | Сдача неполной заявки или после дедлайна не меняет статус; ошибки указывают причину | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); NEG01–NEG03 + current submission/asserted locks | Неполнота открывает Team/case/material control без submit POST. Actual closed-after-confirm POST409 сохраняет draft, PATCH200 после срока разрешен. Current required/deadline/inclusive/rollback assertions прочитаны; normal submit проверяет срок перед snapshot. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC15 | Одновременная сдача и изменение состава или ответов не меняют уже сданные данные | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); Native PostgreSQL focused6 existing races + writer source audit | Submit×submit/PATCH/remove/accept/form/file delete:6/6 OK без skips на test_procollab_ci_unicode; allowed ordered outcomes и snapshot/name/roster/file assertions прочитаны. Shared program→solution locks/atomic; это подмножество340 suite, не новый HTTP/load run. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC16 | Устаревшая форма обновляется без потери локального ввода; новое обязательное поле требуется только несданным работам | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); NEG05/06 + unchanged frontend facade/methods + current anchors | Actual PUT/PATCH409/GET/rebase сохраняет local name/text/0/false/case, new required фокусируется для draft. Current anchors остаются ready.5 core files/6 методов совпадают с FORM ref; новая account очистка отдельно сохраняется. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC17 | Смена подписей и порядка в настройках не меняет представление ранее сданного решения | [DRAFT audit](../qa/program-solutions-draft-acceptance-20261025/README.md); NEG06 + current snapshot test + anchors12/13 GET | После live rename/reorder/new required актуальные form/answers/members submitted anchors совпали с исходным snapshot; current form revision/definitions отличаются, required82/83 не ретроактивны. Readonly renderer использует полученный frozen form. | PASS — DRAFT/NEG/FORM/PART, refs/границы в отчете | +| AC18 | Эксперт оценивает нужное решение; критерии, назначения, расчет и публикация результатов сохраняют текущие правила | solution_evaluation/solution_results; FE07 source и browser reports | Эксперт и manager results/distribution UI должны пройти общий путь; D02 pending, публичные оценки не открываются флагом материалов | OPEN — общий verdict не доказан | +| AC19 | Аналитика и выгрузки считают заявки, принятый состав и применимость материалов без дублей Project | test_solution_manager_analytics.py/test_solution_field_filters.py; FLT evidence | Manager UI counters/filters/XLSX на тех же IDs; API уже проверен, UI D02 еще не подключен | OPEN — общий verdict не доказан | +| AC20 | Несколько программ одного пользователя не смешивают ответы, приглашения, права и оценки | [LEGACY](../qa/program-solutions-legacy-acceptance-20261025/README.md); multi-program/dual-role HTTP | Captain9 programs30/31: answers/invites isolated, wrong GET/PATCH/submit pairs404; Expert15 own scores23/24 and manager23/24 vs expert-only34; colliding Project/Solution3/4 distinct.39 verifier responses,27 C00 responses/2 bodies,22 preexisting domain models unchanged | PASS — LEGACY | +| AC21 | Перенос сохраняет связанные данные и ссылки; повторный запуск не создает дубли | BE10 и утвержденный список программ | Отдельный перенос после REL01, повтор/links/counts проверяются отдельно; не выполнять на реальных программах | Отдельный BE10, не выполнен | +| AC22 | Обычные проекты и старые программы без переноса сохраняют прежнее поведение | [LEGACY](../qa/program-solutions-legacy-acceptance-20261025/README.md); current editor/native PostgreSQL | Actual ordinary Project3 and legacy Project4/program34: edit/team/invite/accept/role edit/decline/revoke, ordinary publish and cancel-confirm-submit link3. Additional tab fix verified;148 legacy tests, old22 domain models unchanged, no new Solution | PASS — LEGACY | +| AC23 | Неконкурсная программа сохраняется без искусственного требования сдачи | [FORM06](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md); actual noncompetitive UI/save/read | Пустой/частичный draft сохраняется без минимума сдачи, submission.required=False, кнопки submit нет, подсказка не обещает сдачу. Прямой submit409/not_competitive без изменения данных; public UI отдельно | PASS — FORM06 | +| AC24 | Форму, приглашения и сдачу можно завершить на мобильном экране | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); PART01 completed phases + INV responsive + current source audit | КЧ/хакатон: actual mobile390 поиск/приглашение/accept/материалы/PDF/0/false/case/confirm/один submit200.320/390/768/1440 проверяют названные состояния. Historical full path и resumed readback различены; нового current full journey не заявляем. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC25 | Заявка имеет вкладки «Решение» и «Команда»; переходы сохраняют ввод и выбранный кейс, сдача проверяет обе вкладки и открывает вкладку с ошибкой | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); PART01 + NEG01/02 + current route tests | Локальные text/0/false/case сохраняются при двух вкладках/PATCH. Team minimum открывает team-title; missing case/file/repository из Team открывает materials и нужный control. Current route tests сохраняют права/данные при refresh. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | +| AC26 | «Пригласить участника» открывает модальный поиск по имени/фамилии в программе; недоступные кандидаты не выбираются, выбранный ID и роль 1–128 символов обязательны, подсказки допускают свободный ввод; pending блокирует повтор, ошибка сохраняет выбор и роль, успех добавляет только ожидающее приглашение; изменение роли и отзыв доступны по текущим правам | [TEAM audit](../qa/program-solutions-team-acceptance-20261025/README.md); INV03–INV06 + C001.16 role fix + TEAM46 HTTP | Program search/selected ID/required role1–128/free suggestion/unavailable/pending/error/retry/edit/revoke проверены в реальном UI. Прямой API обход закрыт:46 responses/38 invalid requests400, без изменения15 domain моделей;10 role tests+historical null,120 прежних raw responses/18 bodies повторно валидны по C001.16. | PASS — TEAM/PART/INV/NEG, refs/границы в отчете | + +## Проверенные границы существующего evidence + +- PART01: реальный участнический путь КЧ/хакатона на указанных в отчете исторических runtime refs прошел в отдельном локальном окружении. TEAM/DRAFT сопоставляют конкретные завершенные фазы с нынешними критериями; общий QA01 остается открытым. Доказательства и границы — ниже. +- Собственный [PostgreSQL CI 1724bca](https://github.com/PROCOLLAB-github/api/actions/runs/40159104055) SUCCESS: 1652 full-suite + 39 locking tests, fresh migrations/checks; Ran/OK проверены в логе. [Общий CI 1724bca](https://github.com/PROCOLLAB-github/api/actions/runs/40159100597) SUCCESS, Tests и Lint. Это проверенные runtime refs, а не незавершенный новый запуск docs commit. +- Логи C:/Users/evsee/AppData/Local/Temp/procollab-solution-field-filters-regression.log и procollab-solution-field-filters-fe-tests.log: 244 PostgreSQL / 2754 frontend и strict C00 types PASS на указанных refs. Source/assertions каждой строки AC требуется привязать отдельно. +- design-artifacts/program-solutions-20261024/expert-v1/implementation/browser-evidence.json: программы 3/4, ширины 320/390/768/1440, 2 POST оценки, errors []. Это экспертный UI на ref того прохода, не новое прохождение всей заявки на текущем ref. +- cabinet-browser-evidence.json: actual mixed cabinet, Project.id=Solution.id=2, обе ветки/return/filter/error/retry; otherWrites=1. Не записывать этот проход как zero writes. +- field-filters-http-evidence.json: 143 HTTP/JWT/anonymous checks КЧ/хакатона/noncompetitive, настоящие filtered книги, domain mutations 0. Не доказывает новые Angular controls или полный жизненный цикл приглашения. +- D02 manager-v1 и D03 public-v1: статические прототипы с отдельным responsive/keyboard/states QA. Это не Angular integration и не design approval. Предыдущие согласования D00 v3/D01 сохраняются. +- NEG01–NEG08, INV01–INV08 и ER01–ER08 уже дополняют PART01: отрицательные сдача/сеть/rebase, role bounds/edit/last-slot и актуальные criteria/role/deadline/own-score checks. [NEG](https://github.com/PROCOLLAB-github/procollab/blob/d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175/docs/qa/program-solutions-negative-20261025/README.md), [INV](https://github.com/PROCOLLAB-github/procollab/blob/d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175/docs/qa/program-solutions-invitations-20261025/README.md), [ER](https://github.com/PROCOLLAB-github/procollab/blob/d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175/docs/qa/program-solutions-expert-revision-20261025/README.md). Их исторические assertions не заменяются общим зеленым числом; строки полного AC scope требуют отдельного сопоставления. +- [MR01–MR07](../qa/program-solutions-membership-races-20261025/README.md): текущие runtime refs, четыре реальные HTTP races двух форм, 31 HTTP responses/24 requests + 16 browser responses C00, 8 завершенных UI checks. Четыре acknowledge-welcome POST явно отделены от нулевых solution writes; все FAIL/partial harness attempts сохранены. Прежние строки 15 выбранных моделей неизменны. Это закрывает AC02/08, не остальные AC или REL01. + +- [FORM01–FORM07](https://github.com/PROCOLLAB-github/procollab/blob/37ecd71d312ff7b8d81849329fcd92fb9f52e6a7/docs/qa/program-solutions-form-settings-20261025/README.md): реальные настройки и draft paths трех форматов,86 C00 responses/22 requests; initial dirty и noncompetitive copy исправлены. Baseline после setup, не pre-seed; ранее сохраненные failed attempts остаются в отчете. Эти данные закрывают названные AC, не весь выпуск. +- [CASE01–CASE08](program-solutions-be08-case-actions.md): подтвержденная смена case answer несданного решения, три observed races включая confirm×submit, frozen anchors и0/false/team/pending сохранены.115 C00 responses/88 valid requests+15 rejected invalid types, final52 tests после guard. Это не реализация удаления/отвязки или решения по submitted policy. + +## PART01: участник КЧ и хакатона — PASS в указанном scope + +[Отчет и точные JSON evidence](../qa/program-solutions-participant-20261025/README.md). Настоящие Angular/JWT/Django/PostgreSQL, пять отдельных аккаунтов и две новые синтетические программы 7/8, решения 6/7; прежние программы 3/4/6 не перезаписывались. Браузер Headless Edge/Playwright, основной полный путь 390 px; отсутствие горизонтального overflow проверено в состояниях team/materials/readonly на 320/390/768/1440 px, материалы дополнительно сняты на desktop. Это не утверждение полного desktop пути, screen reader или browser zoom. + +- AC01/03/04: создан отдельный пустой ProgramSolution с одним капитаном и без ответов/выбранного кейса; состав и приглашения внутри второй вкладки той же заявки. Сохранение не создает Project; моделей самостоятельной Team в runtime нет. +- AC05/07/26: реальный program search, выбор получателя и свободная роль → pending → принятие через уведомление второго аккаунта. Два дополнительных pending дают accepted=2, pending=2, reserved=4. После принятия капитан получил настоящий version_conflict: модальное окно сохранило получателя/роль, повтор завершился 201. Это не проверка отказа пятому участнику, всех длиновых границ роли или редактирования роли. +- AC10/12/13/20/25: разные canonical формы КЧ и хакатона, кейс изначально null; локальный ввод, выбранный кейс, число 0 и checkbox false сохраняются при переходе между вкладками и в JSON после save. Один капитан/участник работают в обеих программах без смешения solution IDs/ответов. Для КЧ загружен настоящий PDF 597 bytes; bytes, MIME, владелец и SHA-256 проверены в локальном хранилище. В хакатоне заполнены обязательные repository/run instructions; необязательная презентация отсутствует. +- AC09/24: отмена подтверждения оставляет draft; подтверждение дает ровно один успешный UI submit POST для каждого решения. Материалы/форма/принятый состав зафиксированы snapshot, редактирующие inputs и управление составом исчезают. После сдачи один pending отклонен получателем, другой отозван капитаном (DELETE 204 удаляет запись; отдельного status=revoked нет). Snapshot/состав не меняются, pending=0, accepted=2. Mont загружен и используется. +- AC06/09: отдельный HTTP verifier выполнил 18 запросов с C00 response validation: captain/member читают только свое решение, declined/revoked recipient получают 404, anonymous 401; member PATCH 403/not_solution_captain, captain PATCH 409/team_frozen, repeat submit 409/already_submitted. До/после отрицательных проверок доменные данные совпадают; published_at остается null. + +Путь намеренно меняет синтетические данные: 28 mutating HTTP requests в сохраненном browser evidence, включая два ожидаемых 409. Это не число сохраненных изменений и не zero writes. Был отдельный failed harness attempt: финальная проверка хакатона прочитала pending раньше завершения DELETE; обе сдачи уже состоялись. Raw attempt сохранен, финальные postconditions перечитаны с явным ожиданием DELETE, повторной сдачи не было. Evidence явно объединяет завершенные фазы и resumed final verification, а не заявляет одну непрерывную успешную invocation. + +Production CDN, внешние avatars и рассылка не использовались. Для QA скопирован готовый development build, только API/WebSocket origins в одном generated chunk заменены на localhost; хеши до/после сохранены. Исходные frontend environments не менялись. Первые неудачные origin/multipart попытки относятся к harness; проксирование файлов заменено прямым browser HTTP. Удален только прежний проверенный draft solution=5 до успешного прогона; сданные 6/7 сохранены. Внешняя сеть блокировалась, поэтому default avatars на screenshots отсутствуют. Три состояния screenshot просмотрены визуально; полная visual/a11y приемка не заявляется. + +Остаются: submit errors/focus обеих вкладок, stale form rebase с вводом, deadline/network negatives, все переходы приглашений и границы ролей, PostgreSQL races с прочитанными assertions, полный organizer/expert/public/legacy/noncompetitive путь. PART01 не закрывает QA01 целиком. + +## GAP-A: административные исключения после сдачи + +Source: management_project_actions.inspect_action/apply_action и tests/test_management_project_actions.py. Legacy организаторский путь после подписанного preview разрешает submit после срока, change_case сданной заявки, detach program link; Project и оценки сохраняются. participant_removal удаляет program link капитана, членство/приглашения и регистрацию, сохраняя исходный Project/ProjectScore. Новое решение владеет данными напрямую, отдельной Project-копии у него нет. + +Противоположный инвариант: AC09 и solution_team.require_mutable/solution_models.save фиксируют ответы/капитана/состав/snapshot после submit. Прежние destructive handlers в solution mode отклоняются. Административные исключения нельзя перенести снятием этого guard или удалением owner с cascade без явного правила для сданной работы/оценок/публичных материалов. + +25.10 пользователь согласовал сохранение административных исключений после сдачи с обязательным сохранением работоспособности и данных старых программ, где заявки — Project. [Решение и следующий scope BE08](program-solutions-be08-admin-exceptions.md). Обычные действия капитана остаются frozen. C00 1.17/BE08A реализовал signed смену кейса после сдачи и историю исходного/нового snapshot, сохраняя оценки и оцененные назначения. [ACH scope](program-solutions-be08-case-history.md). C00 1.18/BE08B добавил signed исключение участника с сохранением материалов/оценок/регистрации и исходного состава в истории; [границы и проверки](program-solutions-be08-member-actions.md). BE08C1/C00 1.19 добавил signed bulk архив со снимком черновика без submit, retention файлов/оценок/состава и active-only selectors; [scope](program-solutions-be08-archive.md). BE08C2/C00 1.20 добавил удаление регистрации и независимый аудит с сохранением архивных работ и ролей; [scope](program-solutions-be08-registration-removal.md). Legacy проекты не удаляются и автоматически не переносятся. Поздняя первая сдача организатором уже реализована отдельно и не перезаписывает сданный snapshot. + +## GAP-B: выключение нового создания при откате + +Прямое требование REL01: «при откате выключить создание новых решений, сохраняя доступ к уже созданным работам и их данным». Исходный source audit выявил отсутствие отдельного переключателя; переключение storage mode обратно в legacy_project блокировало обычные writers существующего решения. + +Технический gap закрыт C00 1.13.0 и [ROL01–ROL10](program-solutions-rel01.md): persisted program-scoped solution_creation_enabled=False, миграция 0034, scoped preview/apply CLI, отдельный context reason/409 и прежний captain repeat 200. Existing read/edit/files/team/invites/submit/evaluation/publication не отключаются; обычный stale program save не отменяет операторский флаг. 17 новых / 261 final PostgreSQL tests, 2760 frontend tests/478 files/strict types/build/lint/format, actual browser КЧ/хакатона и 30 HTTP/C00 checks PASS. Программы 9/10 и решения 8/10 submitted, 9/11 draft; final flags False. Hash всех прежних данных совпадает с baseline до rehearsal. Это не полный REL01, не rollout реальных программ и не закрытие других AC. D02/D03, административная политика и конкурсные черновики остаются отдельными gates. + +## Последовательность оставшейся приемки + +1. PART01 участнического пути обеих форм выполнен в описанном scope. Сохранить решения 6/7 как submitted fixtures; не сбрасывать их для повторного прогона и не выдавать повторное чтение за новое прохождение всех фаз. +2. TEAM/DRAFT сопоставили участнические критерии с NEG/INV/PART/FORM и текущими source/assertions. Остались AC18/19: expert/results/publication и manager analytics UI. AC20/22 закрыты отдельным LEGACY evidence с настоящими старыми маршрутами и изоляцией нескольких программ/ролей. Отдельно завершить недоказанные desktop/organizer/public интеграции; проверка ширин подтверждает только названные состояния. +3. AC02/08 закрыты MR01–MR07 с наблюдаемыми HTTP lock paths; AC07 — отдельным INV last-slot и NEG minimum. AC15 закрыт DRAFT: прочитаны точные ordered outcomes и общие locks, шесть existing concurrency tests повторены на native PostgreSQL без skips. Это отдельное доказательство, не перенос MR verdict и не новый HTTP/load run всех веток. +4. Технический GAP-B реализован и проверен в scope ROL01–ROL10; сохранить runbook/flags при подготовке полного REL01. Поздняя первая сдача организатором реализована и проверена в [SADM01–SADM10](program-solutions-be08-submit-actions.md), без изменения сданного snapshot. GAP-A согласован пользователем: дополнить контракт/preview/apply и приемку привилегированных исключений после сдачи, сохраняя исходные материалы/оценки и прежние Project программы. +5. После D02 approval подключить manager UI, напоминания/действия/распределение/results/counters/export и проверить настоящие роли/ошибки/возврат. После D03 approval подключить public UI; конкурсная публикация остается зависимой от ранее заданного вопроса о drafts. +6. Сохранить ER/LEGACY proof текущих expert/mixed/legacy путей; завершить оставшиеся organizer/results/public интеграции и закрыть каждую строку QA01 конкретным evidence. Только затем общий REL01 rollout/rollback rehearsal и handoff. BE10 не выполнять без успешного первого выпуска и утвержденного списка. + +## Ограничения + +Этот документ фиксирует незавершенную feature, конкретные checkpoints и оставшийся plan/audit. Runtime checks выполнены только в указанном scope. MR не меняет product source/UI, migrations/deploy/реальные программы и не объявляет полный acceptance. Требования не сокращаются до уже реализованных API; полный goal остается активным. D02/D03 и вопрос конкурсных несданных черновиков не переотправляются. Административные исключения согласованы, их реализация и безопасное хранение входят в оставшийся scope. + +## CPUB: материалы сданных конкурсных решений, C00 1.21 + +[CPUB01–08](program-solutions-competitive-publication.md) дополняют API публикации сданных КЧ/хакатон работ и сохраненных неконкурсных материалов. GET200 списка не публикует, marker ставит scoped publisher; исходный снимок и административные ревизии/архив/закрытые оценки сохраняются. Вопрос о несданных конкурсных черновиках остается открытым. Это техническая часть AC18: общий verdict AC18/19 не меняется, новый Angular public/manager UI и полный QA01/REL01 остаются впереди. Все исходные26 строк AC выше сохранены. + +## Совместимость с наблюдаемым dev: текущая граница доказательства + +[Compatibility checkpoint](../qa/program-solutions-dev-compatibility-20261025/README.md), product7cfe01be / HEADa787f0ee: исходная dev certificate0031 и пустая merge0039 устраняют две leaf nodes без изменения старой истории. Native439/OK без skips включает4 legacy publisher tests; после test-only backend guard/portability поправки focused15 PG/OK без skips и15 SQLite/OK с3 PG-only skips подтверждают оба порядка миграций. Counts имеют разные scopes и не складываются. Guarded main forward/recheck сохранил27 прежних моделей, Project4→4, CPUB/C2 и7 старых registration audits; main не откатывался/сбрасывался, реальные программы не переключались. + +[Prospective FE evidence](https://github.com/PROCOLLAB-github/procollab/blob/2dc10ce11c207053ba1ab998f51bad07ba27d15e/docs/qa/program-solutions-dev-integration-20261025/README.md): featureca1b203a+dev08414b79/tree2f4f4f23,2987 tests/484 files/build/strict types/ESLint103/locked format2073 PASS. Все3681 исходных файла сохранились после проверки, новые QA docs не меняют runtime. Actual Linux BE CI40227938298 завершился success: полный PostgreSQL suite1846/2396.359s/OK без skips на synthetic020cc78c (dev587a9155 + featurea787f0ee); отдельные39 locking/vacancy tests входят в1846 и не прибавляются. General CI40227933396 на featurea787f0ee:1812/1863.834s/OK,72 skips (только3 новых — PG-only races), Lint success. Named Cyrillic PDF renderer test действительно выполнен в Linux general CI; PostgreSQL full log имеет verbosity1. [Итоговые receipts/source scopes](../qa/program-solutions-linux-integration-20261025/README.md). Native Windows459 не завершился в FontConfiguration и не объявляется PASS. Эти source/runtime scopes не заменяют общий путь expert→manager results и manager counters/filters/XLSX, поэтому AC18/19 остаются OPEN. Исходные26 строк AC, D02/D03 approval и draft eligibility не менялись; первый выпуск остается в полном scope. + +## Граница изменений, подтвержденная пользователем 25.10.2026 + +Меняется только контур заявки в программе: ProgramSolution вместо Project, поля материалов, команда/приглашения/сдача и непосредственно связанные оценивание, результаты и аналитика. Остальные сведения, разделы, настройки и общая навигация сохраняются. Существующие семь вкладок остаются в прежнем порядке: Участники → Проекты → Эксперты → Результаты → Аналитика → Настройки → Сертификаты; только у solution-программы «Проекты» называется «Заявки». Оценивание остается внутри заявок, команда — второй вкладкой заявки. Старые Project-программы сохраняют название, маршруты, модели и данные; автоматического переноса/удаления нет. + +В pending D02 исправлена ошибочная навигация из четырех вкладок: восстановлены семь прежних вкладок, выбор демонстрационного экрана вынесен в служебную панель макета. Angular navigation array относительно observed dev08414b79 совпадает; новый manager/public Angular UI не считается реализованным. Это уточнение границы, а не согласование D02/D03. Исходные26 AC не меняются, AC18/19 остаются OPEN, AC21 — отдельный BE10. diff --git a/docs/product/program-solutions-rel01.md b/docs/product/program-solutions-rel01.md new file mode 100644 index 00000000..59e08a6f --- /dev/null +++ b/docs/product/program-solutions-rel01.md @@ -0,0 +1,105 @@ +# REL01: отдельное отключение нового создания + +Scope этого этапа — технический пункт REL01 о rollout/rollback; весь выпуск остается зависимым от QA01. Source: [каноническое ТЗ](https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175), REL01 a5436282-a8ed-4c7b-bb51-00cde8bd12ef/e00bad1b-3374-4a2d-8266-57afb7be70b2. Сначала требования/контракт, затем implementation/verification/review. Исходный runtime BE 1724bca, FE e57fb04e; текущий docs-only BE HEAD 40ba5a7. + +## Current behavior + +`solution_api.context_data/create_solution` проверяют storage mode, текущую заявку, регистрацию/manager exception, срок и конфигурацию формы. `create_solution` держит program row lock; повтор капитана возвращает существующее решение до проверки срока. Отдельного creation switch нет. `solution_team.require_mutable` проверяет storage mode для всех обычных writers: возврат к legacy_project блокирует редактирование/приглашения/сдачу существующих решений и не выполняет требование creation-only rollback. + +Два Angular потребителя (`ProgramSolutionComponent`, `DetailProgramInfoService`) сначала открывают currentApplication, затем используют canCreate; blockedReason — открытый string/null. При отказе создания сейчас показывается общий текст о регистрации/сроках. DTO существующей страницы программы перечислены явно; параметры запуска не принимаются management settings API. Legacy API и независимый nextgen контур не используют ProgramSolution creation. + +## Target, роли и техническое решение + +Добавить `PartnerProgram.solution_creation_enabled: bool`, default=False. Выбор program-scoped persisted флага — технический способ выполнить уже заданное требование отдельного пилота/отката, без нового продуктового правила. Миграция оставляет все прежние программы с False, сохраняя storage mode, решения/ответы/команду/приглашения/оценки/публикацию. Никаких реальных программ при разработке не включать. + +Флаг изменяется только оператором через явную scoped management command `set_solution_creation --program-id ID --enable|--disable`; по умолчанию команда делает preview, запись только с `--apply`. Она блокирует одну строку программы, не переключает storage mode и не меняет другие данные. Unknown/unsafe ID и попытка enable legacy_project отклоняются. Текущие REST permissions не расширяются; клиент не передает флаг. Новый пользовательский экран/контрол не нужен; меняется только текст существующего отказа, дизайн D00 сохраняется. + +С выключенным флагом запрещено только создание новой заявки. Капитан по повторному POST получает существующую с 200; accepted member сохраняет прежний 409/already_in_program_team. Чтение, список, формы, файлы, приглашения, принятие в существующую команду, редактирование и сдача существующих работ сохраняют прежние права/сроки/frozen guards. Неконкурсная публикация и эксперт/manager API не проверяют этот флаг. + +## C00 1.13.0 before/after + +- GET `/programs/{program_id}/solution-context/`: прежний Context, без нового обязательного поля. При отсутствии текущей заявки в solution mode у зарегистрированного участника/manager с выключенным флагом can_create=False, blocked_reason=solution_creation_disabled. Порядок: current application → mode → права → creation flag → срок → форма. Existing currentApplication имеет прежний приоритет. +- POST `/programs/{program_id}/solutions/`: прежний payload/status/Response. Для новой заявки с выключенным флагом 409/Error code=solution_creation_disabled, detail «Создание новых решений временно приостановлено. Ранее созданные заявки доступны.». Проверка после currentApplication/прав, до срока/формы; anonymous/outsider/legacy и повтор сохраняют прежние статусы. Ни один клиентский параметр не обходит флаг. +- Остальные операции и 120 схем не меняются; добавляются независимые сценарии. Старый Angular уже запрещает создание по canCreate и корректно открывает существующую заявку. Новый Angular уточняет только текст существующего error state; неизвестные причины сохраняют прежний общий текст. Backend/миграция → frontend. Обычные/legacy проекты продолжают прежний путь. + +## Проверяемые AC и план + +| AC | Предусловие и наблюдаемый результат | +| --- | --- | +| ROL01 | Migration 0033→0034 на существующих программах/решениях: флаг False, остальные доменные данные и mode неизменны; новая программа тоже False. | +| ROL02 | Eligible участник/manager без заявки при False: context запрещает создание; POST 409/C00, нет Solution/Member/Project writes. При True прежний create 201 работает. | +| ROL03 | При False captain repeat POST 200 возвращает тот же ID/name/version, currentApplication открывается; accepted member не создает вторую заявку. | +| ROL04 | False не запрещает save/file binding/invite/accept/role/revoke/team/submit существующей draft; submitted snapshot остается frozen, public/evaluation readers сохраняют прежнее поведение. | +| ROL05 | Anonymous/outsider/unknown/unsafe/legacy и права имеют прежние результаты; can_create и POST согласованы, приоритет причин проверен. | +| ROL06 | CLI preview не пишет, apply меняет флаг только выбранной программы; повтор идемпотентен, ID validation/legacy enable отвергаются. ProgramSolution/version/data/другие программы неизменны. Обычный save устаревшей карточки программы не отменяет committed enable/disable. | +| ROL07 | На PostgreSQL disable и create используют program row lock: запрос, начавшийся после committed disable, не создает; новый create, ожидавший lock при отключении, перечитывает False. Если create завершился первым, существующая заявка сохраняет доступ. | +| ROL08 | Frontend direct new route/program entry при disabled context показывает понятную причину без POST/legacy fallback; existing application открывается. Wire contract fixtures/strict types сохраняют реальные ID/прежние схемы. | +| ROL09 | Actual local HTTP/browser rehearsal отдельной synthetic программы: off → enable → create → disable → повтор/read/edit/invite/submit → re-enable. Никаких real program switches/deploy/рассылок; local storage only. | +| ROL10 | Source audit/relevant tests, migration/check, C00 both copies, frontend tests/build/lint/format и review связываются с каждым AC. У REL01 полный verdict остается открытым до полного QA01 и согласованных UI. | + +Порядок реализации: metadata/migration и scoped CLI → creation/context guard → backend tests/migration/locking → синхронизация C00 и механическое уточнение Angular error text → tests/build → actual HTTP/browser rehearsal → review/evidence/PR handoff. Не зависеть от нерешенных административных исключений, D02/D03 или конкурсной eligibility; не выбирать ответы за пользователя. + +## Rollout и откат + +1. Backend + migration: storage mode по умолчанию legacy_project, creation flag=False, существующие данные не переносятся. Проверить совместимость старого клиента с новым контекстом. +2. Frontend после backend. На отдельной выбранной новой программе настроить solution mode/форму/сроки/команду/эксперта и пройти полный QA01; этот этап еще не выполнен для полного выпуска. +3. Только после приемки выполнить scoped command preview и apply enable для утвержденного ID. Не включать автоматически все solution программы и не привязывать разрешение к is_competitive. +4. При откате command disable для выбранного ID; сохранить solution_storage_mode=solution. Дождаться завершения команды/commit; последующие create отклоняются, existing read/writers продолжают прежние правила. Не откатывать additive column/код к версии без guard и не удалять решения. Data migration назад не является creation-only rollback. +5. После проверки можно повторно enable. Сданные снимки/публикация/версии не сбрасываются. BE10 отдельно по утвержденному списку, не входит в этот rehearsal. + +## Evidence + +**ROL01–ROL10 в техническом scope PASS; весь REL01 и QA01 остаются OPEN.** [Точные evidence](../qa/program-solutions-creation-switch-20261025/README.md). + +| AC | Evidence и предел утверждения | Verdict | +| --- | --- | --- | +| ROL01 | TransactionTestCase миграции и actual local 0033→0034: 8 программ, 6 решений, 23 ответа, 10 участников, 5 приглашений, 12 оценок, 4 назначения, 5 файлов, 2 Project; все прежние columns/data совпали, флаг False. | PASS | +| ROL02 | Disabled context/POST и enabled create API tests; actual browser direct route/program entry, HTTP 409 и CLI enable → 201 для КЧ/хакатона. | PASS | +| ROL03 | Captain repeat без переименования/версии, accepted member без второй заявки; tests и 30 HTTP/C00 checks, currentApplication приоритетен. | PASS | +| ROL04 | При False API tests выполняют file binding/save, invite/edit/accept, transfer/remove/leave, submit и pending decline/revoke; эксперт rate/manager results и неконкурсная публикация читаются. Actual browser обеих форм сохраняет/приглашает/сдает после disable, frozen snapshot и 0/False/file bytes подтверждены. | PASS | +| ROL05 | Tests unknown/unsafe/anonymous/outsider/staff/manager/legacy, false не меняет права/сроки; payload не может передать флаг. | PASS | +| ROL06 | CLI preview/apply/повтор, выбранная программа и один столбец; errors без writes. Model.save сохраняет persisted flag, если update_fields не выбирает его явно. Два направления stale save проверены после review fix. | PASS | +| ROL07 | Два реальных PostgreSQL lock tests подтверждают блокировку worker через pg_blocking_pids, а не только старт потоков: waiting create перечитывает False после commit, waiting disable сохраняет созданную перед ним заявку. | PASS | +| ROL08 | 3 новых wire + 2 route + 1 entry tests (55 focused); существующие new/direct/owned/legacy routes и error states. Новый error text проверен в actual browser, layout не менялся. | PASS | +| ROL09 | Программы 9/10: off → enable → create → disable → сохранение/приглашения/submit → re-enable и новая draft → disable. Решения 8/10 submitted, 9/11 draft, финальные флаги False. 37 mutating browser HTTP (включая expected conflicts), JS errors=0; 30 дополнительных HTTP checks не изменили домен. Hash всех прежних данных совпал с migration baseline. | PASS | +| ROL10 | Final regression 261 PostgreSQL tests, включая 17 новых; Black/Flake8/check/migration consistency/C00 PASS. 2760 frontend tests/478 files + strict types/development build/scoped ESLint/locked Prettier 3.8.3 по 1969 files PASS. Source review выполнен, исправлена перезапись флага stale save, после исправления повторены 261 tests и 30 HTTP с перезапущенным API. Полный Linux CI нового commit ожидается отдельно. | PASS в scope этапа | + +Полный основной browser путь — mobile 390; состояния team/materials/readonly без horizontal overflow при 320/390/768/1440, материалы дополнительно desktop. Paused route и program-entry modal просмотрены визуально; Mont. Browser использовал отдельную копию generated development build с единственным localhost API/WebSocket origin chunk; frontend source environment не менялся. Внешние avatars/CDN/почта/production не проверялись. Полный desktop/a11y/zoom не заявляется. Browser проход предшествовал последней защите stale program save; после нее выполнены final regression и HTTP на перезапущенном сервере, без повторной сдачи fixture. + +Review: contract payload/схемы/прежние примеры сохранены; flag не дает новых REST прав и не меняет срок/состояние существующих работ. Scope исправления только creation/context и служебный flag; legacy regression включена. Test helper solution_program явно включает готовую fixture; production model/migration default проверен False. Старый publication migration test создает current fixture до downgrade и восстанавливает latest graph, чтобы новая колонка не делала тест исторической схемы некорректным. Открытых BLOCKER/MAJOR/MINOR в техническом scope не найдено; прочие gates полной feature сохраняются. + +Для полного выпуска по-прежнему нужны исходный QA01, D02/D03 approval и реализация, административная политика после сдачи и конкурсная eligibility. Операторский rehearsal выполнялся только на новых local synthetic программах; реальные программы/dev/production не включались, merge/deploy не выполнялись. BE10 не выполнялся. + +## Дополнение C00 1.17: история административных изменений + +Перед frontend применяется миграция0035, создающая только SolutionAdministrativeChange/constraints. Исходные Project и snapshots не переносятся и не переписываются миграцией. При откате выключается создание; таблицу истории и миграцию0035 не откатывать вниз: сохраненные ревизии должны оставаться в БД. Existing solution/history/case readers/writers сохраняют свои права при creation flag=False. Это проверено на новых локальных КЧ/хакатоне, но не заменяет полный rollout/rollback первого выпуска. Реальные программы не включались. + +## Дополнение C00 1.18: история исключения участника + +0036 расширяет таблицу истории для remove_member и сохраняет все прежние case rows. В local rehearsal23 выбранные модели, включая историю, совпали до/после миграции. При откате сохраняются новые поля и события; reader должен понимать change_case/remove_member. Creation-only flag не выключает административные действия. Снятие/архив заявки, удаление регистрации и полный rollout/rollback первого выпуска остаются открыты. Реальные программы не включались. + +## Дополнение C00 1.19: архивное участие и минимальная совместимая версия + +0037 применяется до нового backend, затем frontend. Миграция сохраняет прежние23 модели и4 case/member events; архивируются только новые local fixtures явным action. После первого archive event backend/фоновые workers должны сохранять active filters, archive readers и frozen/file guards C00 1.19; более старый backend не является допустимой версией отката. Creation-only flag останавливает только новое создание. Snapshot/история/0037 не откатываются вниз; публичные и экспертные списки продолжают исключать архив. Новый organizer UI, удаление регистрации и полный rollout/rollback rehearsal первого выпуска остаются открыты. + +## Дополнение C00 1.20: удаление регистраций и независимый аудит + +0038 применяется до backend, затем frontend. Миграция добавляет SolutionRegistrationRemoval и nullable-связи истории без переписывания существующих решений/Project. Local rehearsal подтвердил неизменность23 моделей, включая58 решений,4 архива и8 прежних событий. Явное удаление выполнялось только на новых synthetic регистрациях; case/member/archive snapshots и файлы остаются в БД. После первого C2 события backend/workers/readers должны понимать remove_registrations и registration_removal_id C00 1.20. Схема/аудит не откатываются вниз; creation-only flag не прекращает чтение и административные действия. Старые Project endpoints и программы не переключаются. Новый organizer UI/навигация архива, D02/D03 и полный rollout/rollback первого выпуска остаются открыты; BE10 отдельно. + +## Дополнение C00 1.21: публикация сданных конкурсных материалов + +Схема БД не меняется. Новый backend/publisher ставит marker активным submitted решениям solution-программы после datetime_finished при publish_projects_after_finish=True; legacy Project publisher и неконкурсная ветка сохраняются. Frontend использует прежние public DTO, новые примеры/описания C00 и 200 пустого конкурентного списка. Несданные конкурсные черновики остаются закрытыми до отдельного решения, это не окончательная eligibility policy. + +После первого конкурсного marker rollback должен сохранять reader/publisher C00 1.21, если требуется сохранить доступность уже опубликованных конкурсных материалов: старый competitive404 reader их скроет, хотя записи останутся. Creation-only disable по-прежнему касается только нового создания; выключение publication flag не отзывает состоявшийся marker, архив исключает работу из публичной выдачи без удаления материалов/оценок/истории. Реальные программы не переключались; полный rollout/rollback первого выпуска, D02/D03 и QA01/REL01 этим checkpoint не закрываются. + +Сверка последнего dev587a9155 выявила дополнительную leaf0031_programcertificate_rendered_batch рядом с feature0038. В текущем checkout конфликтов графа нет; read-only MigrationLoader overlay двух веток подтверждает конфликт перед будущей интеграцией. Старые migrations не перенумеровывать/удалять: сначала совместимость certificate models и объединенный граф, затем повторные migration/data tests. В этом CPUB checkpoint код веток не сливался и main migrations не применялись; это отдельный required_next первого выпуска. + +## Совместимость с dev: дополнение к порядку выката + +Следующий после CPUB compatibility checkpoint исправляет конфликт двух leaf nodes: [spec и аудит](program-solutions-dev-compatibility.md), [evidence product7cfe01be / HEADa787f0ee](../qa/program-solutions-dev-compatibility-20261025/README.md). Из observed dev587a9155 взяты без изменений nullable ProgramCertificate.rendered_batch и0031_programcertificate_rendered_batch; пустая0039_merge_solution_certificate_branches зависит от certificate0031 и solution0038. Старые migration names/operations не перенумеровываются и не удаляются. C00 остается1.21.0/156 schemas/59 operations/305 scenarios. + +При последующем разрешенном выкате сначала доставляются совместимые models/migration files, затем применяется полный актуальный граф через `python manage.py migrate --noinput`, после него запускаются совместимые backend/workers и frontend. Остановка на target0038 не подтверждает применение certificate0031 и merge0039: итоговый partner_programs leaf должен быть0039, pending migration plan пуст, `makemigrations --check --dry-run` без изменений. Это инструкция порядка, не разрешение включить реальные программы или выполнить deployment. Schema-down после сохраненных snapshots/history/audit недопустим; выключение creation flag остается единственным техническим откатом нового создания, чтение и разрешенные действия существующих заявок продолжаются. + +Guarded localhost main rehearsal уже применил ровно upstream0031 и empty0039 forward: прежние строки/колонки27 моделей совпали до/после и при повторном readonly чтении, SHA256 `74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6`, Project4→4. Main не откатывался/сбрасывался, реальные program flags не менялись; старые программы остаются Project. В main0 PDF rows, поэтому непустой backfill/PDF bytes/token/original rendered_batch подтвержден isolated migration tests обоих порядков, а не данным main rehearsal. + +Текущий и prospective BE graph проверены; actual Linux CI40227938298 на synthetic020cc78c завершил models/graph/empty-database migrate/locking и полный PostgreSQL suite1846/2396.359s/OK без skips. General CI40227933396 на featurea787f0ee завершился1812/1863.834s/OK,72 skips и Lint success. [Итоговые receipts/source scopes](../qa/program-solutions-linux-integration-20261025/README.md). Native439/OK без skips и final focused15 PG/OK без skips имеют точные refs в evidence. Prospective FE ca1b203a+dev08414b79/tree2f4f4f23 прошел2987 tests/484 files/build/types/ESLint/format: [отчет](https://github.com/PROCOLLAB-github/procollab/blob/2dc10ce11c207053ba1ab998f51bad07ba27d15e/docs/qa/program-solutions-dev-integration-20261025/README.md). Expanded local459 не завершился в native Windows FontConfiguration, это не PASS; успешные Linux результаты имеют отдельные refs и не превращают неоконченную Windows попытку в PASS. Общий QA01/REL01, D02/D03 и первый выпуск не закрываются этим дополнением; BE10 остается отдельным. diff --git a/docs/qa/program-solutions-archive-20261025/README.md b/docs/qa/program-solutions-archive-20261025/README.md new file mode 100644 index 00000000..d775499f --- /dev/null +++ b/docs/qa/program-solutions-archive-20261025/README.md @@ -0,0 +1,36 @@ +# BE08C1: архивное участие без потери работы + +25.10.2026. Backend product8bb1c28fb9d72ba7981b24e571f8b438deb9b0ab, runtime f85f610c579dd1595d085d95b07e02c1dbf96213; frontend productbe380c5aad2f25522149999d709e7a75126a6397, build runtime2ac2e7438b1efe716c546f46612ef0a3f99c00fb. [Нормативный scope](../../product/program-solutions-be08-archive.md). C00 1.19.0:145 схем/55 операций/272 независимых сценария, три shared files byte-identical.132 старые схемы,52 операции и250 сценариев неизменны; listSolutions расширен participation filter. + +Signed bulk archive снимает работу с активного участия, сохраняет капитана/состав/ответы/UserFile/оценки и исходную сдачу. Все pending и назначения отзываются, включая оцененные; оценки остаются. Черновик получает отдельный archive snapshot с captured_at, сохраняя draft/submitted_at=null. Архив читается историческим составом и организатором, исключен из текущей заявки/exclusivity/оценивания/аналитики/результатов/распределения/публикации. Новая заявка проверяет прежние регистрацию/срок/форму/creation flag. Файлы архивного черновика защищены до CDN-вызова и через ORM/admin. + +Проверено: + +- 391 native PostgreSQL solution tests/OK.20 новых archive tests входят в391, включая3 observed-lock races archive×archive/submit/score.65 первоначальных regression tests,20 focused и2 historical migration tests — подмножества общего391, не дополнительные уникальные tests. +- 152 legacy Project/invitation/management/case tests/OK по тому же набору12 модулей, что BE08B. Старые Project handlers не менялись; Общие проверки команды используют active solution queryset и сохраняют прежние программы. +- Полный frontend после финальной коррекции mocks:2916 tests/483 files +strict C00 types/OK. Development build/scoped ESLint/locked Prettier3.8.3/2030 files PASS до отчета.103 case/member/archive/history wire checks входят в full suite:33/22/27/21; focused archive/history48 — подмножество103. Новый organizer layout/routes/SCSS не добавлялся. +- Black/Flake8 по измененному runtime/test scope, Django check, migration consistency, C00 и diff checks PASS. Финальные mock-only commits исправляют readiness/pending/effects/даты архивных примеров и семантические проверки C00. Runtime backend не менялся, что проверено diff; frontend full suite повторен. Build predates только fixture correction; generated production types/source совпадают, fixture не имеет production потребителей. + +Первый focused run обнаружил stale test instance в assertion после HTTP archive; fixture перечитывается. Первый full391 обнаружил2 historical migration tests, читавших схему0033/0034 через текущую модель с новыми полями. Они используют исторические модели/полный набор полей соответствующей версии; assertions о сохранности данных не удалены. После исправления2 historical и полный391/152 PASS. Все исходные failure logs сохранены. + +0037 применена только к main local `procollab_ci` и отдельной Unicode test DB. Все старые строки23 выбранных моделей и4 case/member events сохранены: `fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a` до/после схемы и HTTP journeys. Миграция не архивировала существующие решения и не переносила/удаляла Project. + +Настоящий HTTP:109 responses/53 request bodies по C00. Новые программы40/41/42: + +- КЧ40: сданное решение55 с2 оценками и2 назначениями плюс пустой draft56 сняты одной bulk-командой; история5/6. Закрытый срок/creation flag=False не мешает явному снятию. Старый preview после изменения срока отвергнут, повтор apply409; исходная сдача/0/false/состав/оценки/регистрации сохранены, все назначения и pending сняты. +- Хакатон41: draft57 архивирован без submit, история7. Позднее изменение текущей формы не меняет сохраненную форму. Создано новое решение58, тот же участник приглашен/принят; current_application указывает58, старый57 доступен для чтения, active/all lists дают1/2. +- Неконкурсная42: draft59 опубликован штатным local publisher без submit, затем снят, история8. Public list становится пустым/detail404, published_at и материалы сохраняются, повтор publisher не публикует архив. + +Legacy context34 остается Project4/link3; archive endpoint там409, чужая пара program/solution404. Создано5 новых Solution и4 archive events, новых Project=0. Старые case/member anchors1–4 не менялись, все creation flags новых программ в концеFalse. Двоичные файлы/CDN операции в HTTP не выполнялись; file retention проверен native API с CDN mock и ORM. + +HTTP продолжен после штатного401 для нового synthetic extra captain18, созданного factory с is_active=False. Активирован только этот новый prefix-guarded аккаунт. Выполненная сдача55 не повторялась: всего один успешный submit, три успешных archive apply для четырех работ, пять созданий Solution. Неожиданный401 сохранен и валидирован, поэтому это resumed journey. Authorization/JWT/password не записывались; в Git wire скрыты11 signed proof strings, исходный wire проверялся до redaction. + +`evidence.json` хранит hashes26 Python source files/helper/raw artifacts; `http-wire.json`, `migration-evidence.json`, `contract-evidence.json` разделяют scopes. Исходные logs/wire остаются локально в `design-artifacts/program-solutions-20261024/qa01-solution-archive`, private baseline/manifest — TEMP. Собственный8877 wrapper34824/worker7368 остановлен и проверен;8769/8871/8876 сохранены. + +Предыдущий backend b829e526: [PG40202219920](https://github.com/PROCOLLAB-github/api/actions/runs/40202219920)39 locking +1759 full/OK без skips; [General40202216975](https://github.com/PROCOLLAB-github/api/actions/runs/40202216975)1748/OK(skipped64). Это терминальные результаты предыдущего BE08B; CI нового C1 еще проверяется отдельно. + +После первого archive event допустимый backend/worker rollback должен сохранять C00 1.19 readers/active filters/frozen/file guards; schema-down и возврат к backend без архивного состояния теряют эти правила. Creation-only flag не отключает административные команды. Полный rehearsal REL01 еще открыт. + +Первый выпуск не завершен: BE08C2 удаления регистрации/аудита, D02/D03 и соответствующие screens/навигация архива, конкурсные несданные черновики и полный QA01/REL01 остаются в scope. Все26 исходных AC сохранены;23 PASS, AC18/19 OPEN, AC21 отдельно BE10. Без merge/deploy/реальных переключений/переноса Project. + +Финальная locked format проверка с отчетом:2035 files, failures=[]. diff --git a/docs/qa/program-solutions-archive-20261025/contract-evidence.json b/docs/qa/program-solutions-archive-20261025/contract-evidence.json new file mode 100644 index 00000000..e0de57ce --- /dev/null +++ b/docs/qa/program-solutions-archive-20261025/contract-evidence.json @@ -0,0 +1,12 @@ +{ + "version": "1.19.0", + "schemas": 145, + "operations": 55, + "scenarios": 272, + "oldSchemasUnchanged": 132, + "oldOperationsUnchanged": 52, + "expandedOldOperation": "listSolutions/participation", + "oldScenariosUnchanged": 250, + "sharedByteIdentical": 3, + "originalQaRequirementRowsUnchanged": 26 +} diff --git a/docs/qa/program-solutions-archive-20261025/evidence.json b/docs/qa/program-solutions-archive-20261025/evidence.json new file mode 100644 index 00000000..5a375871 --- /dev/null +++ b/docs/qa/program-solutions-archive-20261025/evidence.json @@ -0,0 +1,253 @@ +{ + "status": "PASS", + "scope": "BE08C1 archive/retention and frontend API, registration removal C2 and full QA01/REL01 still open", + "backendProduct": "8bb1c28fb9d72ba7981b24e571f8b438deb9b0ab", + "backendRuntime": "f85f610c579dd1595d085d95b07e02c1dbf96213", + "frontendProduct": "be380c5aad2f25522149999d709e7a75126a6397", + "frontendBuildRuntime": "2ac2e7438b1efe716c546f46612ef0a3f99c00fb", + "c00": { + "version": "1.19.0", + "schemas": 145, + "operations": 55, + "scenarios": 272, + "oldSchemasUnchanged": 132, + "oldOperationsUnchanged": 52, + "expandedOldOperation": "listSolutions/participation", + "oldScenariosUnchanged": 250, + "sharedByteIdentical": 3, + "originalQaRequirementRowsUnchanged": 26 + }, + "tests": { + "nativePostgresSolution": 391, + "newArchive": 20, + "newObservedRaces": 3, + "historicalMigrationSubset": 2, + "legacy": 152, + "legacyModules": 12, + "frontend": 2916, + "frontendFiles": 483, + "archiveHistoryWire": 48, + "allAdminWire": 103, + "newArchiveWire": 27, + "newHistoryWire": 2, + "strictTypes": true, + "developmentBuild": true, + "scopedEslint": true, + "black": true, + "flake8": true, + "djangoCheck": true, + "migrationConsistency": true, + "lockedFormat": { + "prettier": "3.8.3", + "locked": "3.8.3", + "checked": 2030, + "failures": [], + "filesWithReport": 2035, + "finalFailures": [] + } + }, + "http": { + "status": "PASS", + "c00": "1.19.0", + "validation": { + "responses": 109, + "validRequests": 53 + }, + "responses": 109, + "programs": [ + { + "id": 40, + "kind": "case", + "solutions": [ + { + "id": 55, + "change": 5 + }, + { + "id": 56, + "change": 6 + } + ], + "replacement": null, + "state": { + "evaluationSeeded": true, + "closed": true, + "applied": true, + "complete": true + } + }, + { + "id": 41, + "kind": "hack", + "solutions": [ + { + "id": 57, + "change": 7 + } + ], + "replacement": 58, + "state": { + "applied": true, + "formChanged": true, + "replacementComplete": true, + "complete": true + } + }, + { + "id": 42, + "kind": "public", + "solutions": [ + { + "id": 59, + "change": 8 + } + ], + "replacement": null, + "state": { + "published": true, + "applied": true, + "complete": true + } + } + ], + "oldModelsUnchanged": 23, + "oldRowsHash": "fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a", + "originalAdminEventsPreserved": 4, + "newProjects": 0, + "newSolutions": 5, + "newArchiveEvents": 4, + "onlyFreshLocalPrograms": true, + "creationFlagsFinalFalse": true, + "noBinaryOrCdnOperations": true + }, + "migration": { + "status": "PASS", + "migration": "0037_solution_archive_retention", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a", + "hashAfter": "fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a", + "rowCounts": { + "partner_programs.PartnerProgram": 37, + "partner_programs.PartnerProgramField": 268, + "partner_programs.PartnerProgramUserProfile": 106, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 53, + "partner_programs.ProgramSolutionAnswer": 255, + "partner_programs.ProgramSolutionMember": 71, + "partner_programs.SolutionInvitation": 34, + "project_rates.SolutionScore": 32, + "project_rates.SolutionExpertAssignment": 20, + "project_rates.Criteria": 64, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 25, + "partner_programs.PartnerProgram_managers": 41, + "partner_programs.SolutionAdministrativeChange": 4 + }, + "existingAdminEventsPreserved": 4, + "existingSolutionsArchivedOrDeleted": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true + }, + "proofRedactions": 11, + "sourceHashes": { + "docs/contracts/program-solutions-v1/validate_contract.py": "7168b95906c1f2759bf5b82c73a145569e80eae4babc20e4f8617d5b84eb1766", + "files/admin.py": "72595b9d8ba3188bc4892db263d126466e6e55bcb9f676c412dcb1ad40cdbe72", + "partner_programs/migrations/0037_solution_archive_retention.py": "ba3010e9df7bacfeaedc72309efe714a19508d4f8242404b3e74f23581e11377", + "partner_programs/services/solution_archive_actions.py": "70fb4ab7e3d8dd784e867ace121da93603e0d1eb90e63571d429db93093141d2", + "partner_programs/services/solution_attention.py": "df5fb2f786f0b2a80a2872c53ce7ceced635105f6d9a71099a972e53ad3b4b3c", + "partner_programs/services/solution_role_analytics.py": "25008ae0381d927655c29331be67424040c013dc829d7297547052e773946f3b", + "partner_programs/solution_admin.py": "19aba5273777fe7e2a73c367010546bd5ac2fc505f09e48a4ea324ed7ac2ef09", + "partner_programs/solution_api.py": "65aabdb05e7e633be98664bc13612148b2e4cb24e84baf69c642b377872d4fdb", + "partner_programs/solution_archive_action_api.py": "d7e4cd44a30a23df3b12419774e27e9c1bc56fe0b9d751da531c1571c26ff0fd", + "partner_programs/solution_file_signals.py": "af7ebe245acdb1216c03f7be597de2daa55067369a62d81fd0eff0a4d31ff919", + "partner_programs/solution_files.py": "9d76e69bbdec292d44a75fb21d0f2dbe182079b769be2a427c75bc262970c9b1", + "partner_programs/solution_history_api.py": "75cb49f32cf630628124706a1d8bc011e995c5ecfe39413943671646206af580", + "partner_programs/solution_invitation_api.py": "4b71780a995bf74299695ce4f055e5b5c753df941089640a2d46fa0cedc41dee", + "partner_programs/solution_invitations.py": "b15e67348f6367aa62fa8ad37c4498346a2fd8075cbbf20de647e74f11d1eba5", + "partner_programs/solution_lifecycle_signals.py": "6ac757b56cf4f24bcb0954796c6042492df5b215d40df54f5021ef28bc98425a", + "partner_programs/solution_models.py": "11ffd9375d4469764b3d916f11e56baff6623364d3cb25975abbb96d3b03d9df", + "partner_programs/solution_participants.py": "085578ea902f5c50f4f8d2dce589cee3bbe8caa7570f471239488d1f930c347d", + "partner_programs/solution_team.py": "5a1abd184887b82ff249be5f3ac102145cdabdf744aaeb5123f5bd2f34c7802b", + "partner_programs/tests/test_solution_archive_actions.py": "440cdad58fa782028f7a8935b11c40e4cce9572e8a6e7a9885eed4c87343ff67", + "partner_programs/tests/test_solution_creation_switch.py": "375f83779a2a24528755952e43488898023b6cf6c9fc4a09a9fc8270b146ed27", + "partner_programs/tests/test_solution_publication.py": "45a9cfb4836bb8e380c91f9d0f06ae1a869d6e89a0de8d9916549054cb15896f", + "partner_programs/urls.py": "774dd797fe53ef8556d8995c35fe8a60e3fccc9fb05f71a08ea9bfe2e0b24536", + "project_rates/solution_allocation.py": "e3f1948804bdd45b2022cba1f758f9a2893750458228d47f8adbbbb59cd9f075", + "project_rates/solution_assignments.py": "54bea9618b9f068b197177a4f2aa98e65706cf42c2c6ddf9d88c28db47809dbb", + "project_rates/solution_evaluation.py": "1128a5d0d5637d4d143b7deec023d98cdb9a22f88f2e4e8c474c7055401558d2", + "project_rates/solution_models.py": "18d1425aab284df38dda08e11df8b6f646db40fcb7b1507f6ef35f80ba970776" + }, + "rawArtifacts": { + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\contract-evidence.json": "441e86e07245f49183b50646b77bf0126793e5ba34c18385f6f11ba8915c6109", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\http-evidence.json": "7ddb3be60fffc409b3b2108ed127c2aff89706d6d0d3fbff64ffcc7f398f56f9", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\http-wire.json": "f96be14e1e5ac5f3a8d3689de8348c0fa865ee77bb88b95574d33c6cc888a935", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\migration-evidence.json": "532f5b61c6391e3ae2d5d0639f93fd4c4a58702fdc74fa5a94447fede6db6ca8", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-black.log": "bdff422390c0bebd756246d312c08c0b6286dc97ca3e8b713be43dcb5c44249b", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-build.log": "b8e4764a1288f597dfcb86bf3f62a9b031c30759c1ae03391371117ca44d5697", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-check.log": "f87ceb15410bde28ff826ce26228e0576558258fa64d145847921a593a61ee63", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-eslint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-final-contract.log": "a205d39f53ef64f1ea64a2cf56ee600c34ab1602900bf3694c37e3d4598e1bc9", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-flake8.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-focused-verified.log": "9be1b0f6a522b3648fb447b6a84bf1baf892dab95ecc12d4b1759f063921b6f6", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-focused.log": "72e5d96b86f3907bf8a7184c5cd718fd9bcd73606412ab97d5cd1d07214c2809", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-format.log": "993ed0551f1efe9b448c1f2bd856b7ae89a46e16aab4722d2850c454aa80d758", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-frontend-suite-verified.log": "3dba7acde7c06d93df48bd35f7bdad5636baa48b07550322b7e8e6af0e09e8ad", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-frontend-suite.log": "d77435672f0990b17f5a9d7dfb90605022cbf090765afda87bd136baa88c10d7", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-historical-migration-tests.log": "1a9958c529fa8219907f608e464c7f65aea2255a9dfaa6c12515205f13566485", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-http-resumed.log": "f9bbe98459e17eece6057a4c420c5ada06eb23bf6ad8f5771d0c705a7d298c84", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-http.log": "be36657711cd16f88f91c9bb9255223ad259ed2299bf39a995ba2e363c06fc70", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-legacy.log": "b3be35dd6025ffe5a46058eaa2c2c92127d52863422911f3d67442eb5a60c8ec", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-migrate.log": "5424e3997f4cd934ecf7c883c0f41501cff8505da39d74600df0d8c745908f8b", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-migration-check.log": "93e6e2d5be3c054add80c0f19101608803f7ca8a7df1fa41f76bbb9a1f570f4a", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-preceding-general-ci.log": "fc594849b196dd11cf9c520cf1521cf37bc754217d634883dc9fab3b597b8d88", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-preceding-pg-ci.log": "f9237d37874bdf7d7311dbacf582fe264f5324611a3a6aa2fb1f38a54a80fcb8", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-preceding-tests.log": "2dc8ff14fbac7dc2b0644b17fc704d749329879dbc1c3367d477e5c73d7d0700", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-solution-suite-verified.log": "b397e31a34b5d3f7fb9988e723bce36bc8fd4d57bfc39c85edb4e801a8057ea5", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-solution-suite.log": "49afd06ab3b39e462bf67e3be700c2efdd09b920d04428835c546e8c7633899c", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-wire.log": "9d94349d4bc6cc1c2f16d08e4caff3c83fd8fd03a72e68c1aaf6fe4f1ab19ec4", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-solution-archive\\procollab-solution-archive-final-format.log": "efe9afb94da42b6f5eaf765b19394204a82e2ac57347ce641dd014b4feb5b554" + }, + "helpers": { + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-solution-archive-contract.py": "2d89c33b0af3dc4abcdb592b63ad432cc4f6e763526ff9961f66d2c0d3717982", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-solution-archive-migrate.py": "f7659b22a53cf0d1ec92281dc70a31073442b9fe28ff4d5c04bf07e8b7e12b9a", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-solution-archive-http.py": "50dff3e90a9a2420efdba5194aeb0176bbb6588724784d7d9e236317e6818afa", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-solution-archive-package.py": "504060636589935259b65fd2d2afc49659980fd65fac7275d47cd80b807e2546" + }, + "precedingCi": { + "head": "b829e526df91718412174bfd3d272c163eca6c72", + "postgres": { + "id": 40202219920, + "status": "completed/success", + "lockingTests": 39, + "fullTests": 1759, + "skips": 0 + }, + "general": { + "id": 40202216975, + "status": "completed/success", + "fullTests": 1748, + "skipped": 64 + }, + "notProofForNewProduct": true + }, + "limitations": [ + "New UI/archive navigation still follows pending D02/D03 design gates", + "Registration removal and audit remain BE08C2; archive retains registrations", + "Full QA01/REL01 still open, BE10 separate", + "No merge/deploy/real-program switching or Project conversion", + "First native failures were a stale fixture assertion and two historical-schema tests using the current model; corrected, checks preserved", + "Actual HTTP resumed after activating only the new synthetic extra captain18; unexpected401 retained and validated, no repeated submit or confirmed archive", + "Final C00 mocks corrected pending/readiness/effect examples using actual archive state; backend runtime unchanged, final frontend suite repeated", + "Development build predates fixture-only correction; production source/types unchanged and fixtures have no non-test consumers", + "Own8877 API stopped after HTTP, old8769/8871/8876 retained" + ] +} diff --git a/docs/qa/program-solutions-archive-20261025/http-wire.json b/docs/qa/program-solutions-archive-20261025/http-wire.json new file mode 100644 index 00000000..6e6008bd --- /dev/null +++ b/docs/qa/program-solutions-archive-20261025/http-wire.json @@ -0,0 +1,9149 @@ +[ + { + "actor": "captain", + "method": "POST", + "path": "/programs/40/solutions/", + "status": 201, + "request": { + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "response": { + "id": 55, + "program_id": 40, + "version": 1, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:49.319268Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 1, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:49.319268Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/40/solutions/55/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 58, + "version": 1, + "program": { + "id": 40, + "name": "Архив решений case qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:13:49.623020Z", + "updated_at": "2026-10-25T15:13:49.623020Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/58/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 58, + "version": 2, + "program": { + "id": 40, + "name": "Архив решений case qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:13:49.623020Z", + "updated_at": "2026-10-25T15:13:49.709941Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 3, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:49.712967Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/40/solutions/55/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 17, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 59, + "version": 1, + "program": { + "id": 40, + "name": "Архив решений case qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 17, + "first_name": "Rate", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:13:49.867351Z", + "updated_at": "2026-10-25T15:13:49.867351Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 4, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:49.870368Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "answers": [ + { + "field_id": 280, + "value": "Город" + }, + { + "field_id": 281, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 282, + "value": 0 + }, + { + "field_id": 283, + "value": false + } + ] + }, + "response": { + "id": 55, + "program_id": 40, + "version": 5, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:50.055290Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 280, + "value": "Город" + }, + { + "field_id": 281, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 282, + "value": 0 + }, + { + "field_id": 283, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/40/solutions/55/submit/", + "status": 200, + "request": { + "expected_version": 5, + "expected_form_revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673" + }, + "response": { + "id": 55, + "program_id": 40, + "version": 6, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:50.167023Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 280, + "value": "Город" + }, + { + "field_id": 281, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 282, + "value": 0 + }, + { + "field_id": 283, + "value": false + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "POST", + "path": "/programs/40/solutions/", + "status": 401, + "request": { + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "response": { + "code": "authentication_required", + "detail": "{'detail': ErrorDetail(string='User is inactive', code='authentication_failed'), 'code': ErrorDetail(string='user_inactive', code='authentication_failed')}", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "POST", + "path": "/programs/40/solutions/", + "status": 201, + "request": { + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "response": { + "id": 56, + "program_id": 40, + "version": 1, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "captain_id": 18, + "created_at": "2026-10-25T15:15:58.958912Z", + "updated_at": "2026-10-25T15:15:58.958912Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 18, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:15:58.962976Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/40/solutions/56/", + "status": 200, + "request": null, + "response": { + "id": 56, + "program_id": 40, + "version": 1, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "captain_id": 18, + "created_at": "2026-10-25T15:15:58.958912Z", + "updated_at": "2026-10-25T15:15:58.958912Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 18, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:15:58.962976Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [55, 56] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "solution_version": 6, + "is_submitted": true, + "is_archived": false, + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "errors": [] + }, + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "solution_version": 1, + "is_submitted": false, + "is_archived": false, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [] + } + ], + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 6, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:13:50.167023Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T15:15:59.213094Z", + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/40/solutions/56/", + "status": 200, + "request": null, + "response": { + "id": 56, + "program_id": 40, + "version": 1, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "captain_id": 18, + "created_at": "2026-10-25T15:15:58.958912Z", + "updated_at": "2026-10-25T15:15:58.958912Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 18, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:15:58.962976Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 40, + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 280, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": false, + "deadline": "2026-10-24T15:15:59.213094Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 280 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 281 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 282 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 283 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/apply/", + "status": 409, + "request": { + "ids": [55, 56], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [55, 56] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [55, 56] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "expert", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [55, 56] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 401, + "request": { + "ids": [55, 56] + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [56, 55] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "solution_version": 6, + "is_submitted": true, + "is_archived": false, + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "errors": [] + }, + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "solution_version": 1, + "is_submitted": false, + "is_archived": false, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [] + } + ], + "score_count": 2, + "removed_assignments_count": 2, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [56, 55], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "changed_count": 2, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "solution_version": 7, + "archived_at": "2026-10-25T15:15:59.971534Z" + }, + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "solution_version": 2, + "archived_at": "2026-10-25T15:15:59.971534Z" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/apply/", + "status": 409, + "request": { + "ids": [56, 55], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [55, 56] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "solution_version": 7, + "is_submitted": true, + "is_archived": true, + "score_count": 2, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [ + { + "path": "ids", + "code": "solution_archived", + "detail": "Решение уже снято с участия.", + "field_id": null + } + ] + }, + { + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "solution_version": 2, + "is_submitted": false, + "is_archived": true, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [ + { + "path": "ids", + "code": "solution_archived", + "detail": "Решение уже снято с участия.", + "field_id": null + } + ] + } + ], + "score_count": 2, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": false, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 7, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:15:59.971534Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T15:15:59.213094Z", + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 200, + "request": null, + "response": { + "id": 55, + "program_id": 40, + "version": 7, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:13:49.319268Z", + "updated_at": "2026-10-25T15:15:59.971534Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T15:15:59.213094Z", + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/58/", + "status": 200, + "request": null, + "response": { + "id": 58, + "version": 2, + "program": { + "id": 40, + "name": "Архив решений case qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:13:49.623020Z", + "updated_at": "2026-10-25T15:13:49.709941Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/management/solutions/55/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 5, + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:15:59.985728Z", + "before_version": 6, + "after_version": 7, + "removed_assignments_count": 2, + "score_count": 2, + "was_submitted": true, + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached", + "revoked_invitation_count": 1 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/management/solutions/55/history/5/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 5, + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 55, + "name": "Работа captain case qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:15:59.985728Z", + "before_version": 6, + "after_version": 7, + "removed_assignments_count": 2, + "score_count": 2, + "was_submitted": true, + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached", + "revoked_invitation_count": 1 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T15:13:50.163992Z" + }, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "answers": [ + { + "value": "Город", + "field_id": 280 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 281 + }, + { + "value": 0, + "field_id": 282 + }, + { + "value": false, + "field_id": 283 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:13:49.324074Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:13:49.705223Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "captured_at": "2026-10-25T15:15:59.971534Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/management/solutions/55/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/40/solutions/55/", + "status": 409, + "request": { + "expected_version": 7, + "expected_form_revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "name": "Недоступная правка" + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/40/solutions/56/", + "status": 200, + "request": null, + "response": { + "id": 56, + "program_id": 40, + "version": 2, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "captain_id": 18, + "created_at": "2026-10-25T15:15:58.958912Z", + "updated_at": "2026-10-25T15:15:59.971534Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 18, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T15:15:58.962976Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": false, + "deadline": "2026-10-24T15:15:59.213094Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/management/solutions/56/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 6, + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:15:59.996926Z", + "before_version": 1, + "after_version": 2, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached", + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/management/solutions/56/history/6/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 6, + "application": { + "kind": "solution", + "program_id": 40, + "solution_id": 56, + "name": "Работа extra case qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:15:59.996926Z", + "before_version": 1, + "after_version": 2, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached", + "revoked_invitation_count": 0 + }, + "before_snapshot": null, + "after_snapshot": null, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 280, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 281, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 282, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 283, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "template": "case_championship", + "program_id": 40, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "answers": [], + "members": [ + { + "role": null, + "user": { + "id": 18, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T15:15:58.962976Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 18, + "captured_at": "2026-10-25T15:15:59.971534Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/40/management/solutions/56/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "extra", + "method": "PATCH", + "path": "/programs/40/solutions/56/", + "status": 409, + "request": { + "expected_version": 2, + "expected_form_revision": "d3bc34b553e60e98aa4a0525fbf46f412a3626cec43b6962e920110351587673", + "name": "Недоступная правка" + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/solutions/", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/40/solutions/?participation=archived", + "status": 200, + "request": null, + "response": { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 55, + "program_id": 40, + "name": "Работа captain case qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "accepted_count": 2, + "case_value": "Город", + "status": "submitted", + "submitted_at": "2026-10-25T15:13:50.163992Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached" + } + }, + { + "id": 56, + "program_id": 40, + "name": "Работа extra case qa01arc-20261025-0674eb82", + "captain": { + "id": 18, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "accepted_count": 1, + "case_value": null, + "status": "draft", + "submitted_at": null, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:15:59.971534Z", + "reason": "detached" + } + } + ] + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/40/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 40, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": false, + "submission_deadline": "2026-10-24T15:15:59.213094Z", + "can_create": false, + "blocked_reason": "solution_creation_disabled", + "current_application": null + }, + "cacheControl": null + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/40/solutions/55/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/40/solutions/55/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/solutions/", + "status": 201, + "request": { + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "response": { + "id": 57, + "program_id": 41, + "version": 1, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:01.789735Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 1, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:01.789735Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/solutions/57/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 60, + "version": 1, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:16:02.016892Z", + "updated_at": "2026-10-25T15:16:02.016892Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/60/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 60, + "version": 2, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:16:02.016892Z", + "updated_at": "2026-10-25T15:16:02.176162Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 3, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:02.179179Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/solutions/57/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 17, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 61, + "version": 1, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 17, + "first_name": "Rate", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:16:02.389535Z", + "updated_at": "2026-10-25T15:16:02.389535Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 4, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:02.393558Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "answers": [ + { + "field_id": 284, + "value": "Город" + }, + { + "field_id": 285, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 286, + "value": 0 + }, + { + "field_id": 287, + "value": false + }, + { + "field_id": 288, + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82" + } + ] + }, + "response": { + "id": 57, + "program_id": 41, + "version": 5, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:02.618573Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 284, + "value": "Город" + }, + { + "field_id": 285, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 286, + "value": 0 + }, + { + "field_id": 287, + "value": false + }, + { + "field_id": 288, + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 5, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:02.618573Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 284, + "value": "Город" + }, + { + "field_id": 285, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 286, + "value": 0 + }, + { + "field_id": 287, + "value": false + }, + { + "field_id": 288, + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [57] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [57] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "expert", + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [57] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 401, + "request": { + "ids": [57] + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [57] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "solution_version": 5, + "is_submitted": false, + "is_archived": false, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "errors": [] + } + ], + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/41/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [57], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "solution_version": 6, + "archived_at": "2026-10-25T15:16:03.341730Z" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/41/management/solutions/archive/apply/", + "status": 409, + "request": { + "ids": [57], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/41/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [57] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "solution_version": 6, + "is_submitted": false, + "is_archived": true, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [ + { + "path": "ids", + "code": "solution_archived", + "detail": "Решение уже снято с участия.", + "field_id": null + } + ] + } + ], + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": false, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 6, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:03.341730Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 284, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "program_id": 41, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 284 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 285 + }, + { + "value": 0, + "field_id": 286 + }, + { + "value": false, + "field_id": 287 + }, + { + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82", + "field_id": 288 + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 6, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:03.341730Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 284, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "program_id": 41, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 284 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 285 + }, + { + "value": 0, + "field_id": 286 + }, + { + "value": false, + "field_id": 287 + }, + { + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82", + "field_id": 288 + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/60/", + "status": 200, + "request": null, + "response": { + "id": 60, + "version": 2, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:16:02.016892Z", + "updated_at": "2026-10-25T15:16:02.176162Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/41/management/solutions/57/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 7, + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:16:03.353901Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached", + "revoked_invitation_count": 1 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/41/management/solutions/57/history/7/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 7, + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 57, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:16:03.353901Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached", + "revoked_invitation_count": 1 + }, + "before_snapshot": null, + "after_snapshot": null, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 284, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "program_id": 41, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "answers": [ + { + "value": "Город", + "field_id": 284 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 285 + }, + { + "value": 0, + "field_id": 286 + }, + { + "value": false, + "field_id": 287 + }, + { + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82", + "field_id": 288 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "captured_at": "2026-10-25T15:16:03.341730Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/management/solutions/57/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/41/solutions/57/", + "status": 409, + "request": { + "expected_version": 6, + "expected_form_revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "name": "Недоступная правка" + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/41/solutions/", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/41/solutions/?participation=archived", + "status": 200, + "request": null, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 57, + "program_id": 41, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "accepted_count": 2, + "case_value": "Город", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached" + } + } + ] + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 41, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T15:13:49.124223Z", + "can_create": true, + "blocked_reason": null, + "current_application": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "program_id": 41, + "version": 6, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:01.789735Z", + "updated_at": "2026-10-25T15:16:03.341730Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:01.793752Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:02.169575Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 284, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2511a9be005b5bad6b5e75d3ae6ebd9281d08f1d58e7969b61488f37dc1280bb", + "template": "hackathon", + "program_id": 41, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 284 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 285 + }, + { + "value": 0, + "field_id": 286 + }, + { + "value": false, + "field_id": 287 + }, + { + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82", + "field_id": 288 + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/solutions/", + "status": 201, + "request": { + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "response": { + "id": 58, + "program_id": 41, + "version": 1, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:04.681246Z", + "updated_at": "2026-10-25T15:16:04.681246Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:04.684851Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "0153799845a3ecb88717f1c6620ef08a28b8a99839ddd4649ff61fcd43ebe7df", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Новая форма после снятия заявки", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/58/", + "status": 200, + "request": null, + "response": { + "id": 58, + "program_id": 41, + "version": 1, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:04.681246Z", + "updated_at": "2026-10-25T15:16:04.681246Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:04.684851Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "0153799845a3ecb88717f1c6620ef08a28b8a99839ddd4649ff61fcd43ebe7df", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Новая форма после снятия заявки", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/41/solutions/58/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 62, + "version": 1, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 58, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:16:04.927516Z", + "updated_at": "2026-10-25T15:16:04.927516Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/62/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 62, + "version": 2, + "program": { + "id": 41, + "name": "Архив решений hack qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 41, + "solution_id": 58, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:16:04.927516Z", + "updated_at": "2026-10-25T15:16:05.158491Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 41, + "solution_id": 58, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/58/", + "status": 200, + "request": null, + "response": { + "id": 58, + "program_id": 41, + "version": 3, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:04.681246Z", + "updated_at": "2026-10-25T15:16:05.161575Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:04.684851Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:05.153381Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "0153799845a3ecb88717f1c6620ef08a28b8a99839ddd4649ff61fcd43ebe7df", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Новая форма после снятия заявки", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 284 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 285 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 286 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 287 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 288 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/41/solutions/58/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "0153799845a3ecb88717f1c6620ef08a28b8a99839ddd4649ff61fcd43ebe7df", + "answers": [ + { + "field_id": 284, + "value": "Город" + }, + { + "field_id": 285, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 286, + "value": 0 + }, + { + "field_id": 287, + "value": false + }, + { + "field_id": 288, + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82" + } + ] + }, + "response": { + "id": 58, + "program_id": 41, + "version": 4, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:04.681246Z", + "updated_at": "2026-10-25T15:16:05.390790Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:04.684851Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:05.153381Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 41, + "revision": "0153799845a3ecb88717f1c6620ef08a28b8a99839ddd4649ff61fcd43ebe7df", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 284, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 285, + "type": "textarea", + "purpose": "other", + "label": "Новая форма после снятия заявки", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 286, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 287, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 288, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 284, + "value": "Город" + }, + { + "field_id": 285, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 286, + "value": 0 + }, + { + "field_id": 287, + "value": false + }, + { + "field_id": 288, + "value": "https://example.com/archive-code/qa01arc-20261025-0674eb82" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/41/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 41, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T15:13:49.124223Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "solution", + "program_id": 41, + "solution_id": 58, + "name": "Работа captain hack qa01arc-20261025-0674eb82" + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/", + "status": 200, + "request": null, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 58, + "program_id": 41, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "accepted_count": 2, + "case_value": "Город", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "active", + "archived_at": null, + "reason": null + } + } + ] + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/41/solutions/?participation=all", + "status": 200, + "request": null, + "response": { + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 57, + "program_id": 41, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "accepted_count": 2, + "case_value": "Город", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:03.341730Z", + "reason": "detached" + } + }, + { + "id": 58, + "program_id": 41, + "name": "Работа captain hack qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "accepted_count": 2, + "case_value": "Город", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "active", + "archived_at": null, + "reason": null + } + } + ] + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/42/solutions/", + "status": 201, + "request": { + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "response": { + "id": 59, + "program_id": 42, + "version": 1, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:05.951934Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 42, + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 289, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": null, + "response": { + "id": 59, + "program_id": 42, + "version": 1, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:05.951934Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 42, + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 289, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/42/solutions/59/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 63, + "version": 1, + "program": { + "id": 42, + "name": "Архив решений public qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T15:16:06.188824Z", + "updated_at": "2026-10-25T15:16:06.188824Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/63/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 63, + "version": 2, + "program": { + "id": 42, + "name": "Архив решений public qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:16:06.188824Z", + "updated_at": "2026-10-25T15:16:06.326385Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": null, + "response": { + "id": 59, + "program_id": 42, + "version": 3, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:06.328494Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 42, + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 289, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "answers": [ + { + "field_id": 289, + "value": "Город" + }, + { + "field_id": 290, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 291, + "value": 0 + }, + { + "field_id": 292, + "value": false + } + ] + }, + "response": { + "id": 59, + "program_id": 42, + "version": 4, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:06.548961Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 42, + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 289, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 289, + "value": "Город" + }, + { + "field_id": 290, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 291, + "value": 0 + }, + { + "field_id": 292, + "value": false + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": null, + "method": "GET", + "path": "/programs/42/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 42, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "published_at": "2026-10-25T15:16:06.630041Z", + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": null, + "response": { + "id": 59, + "program_id": 42, + "version": 4, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:06.548961Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 42, + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 289, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 289, + "value": "Город" + }, + { + "field_id": 290, + "value": "Сохраненные материалы qa01arc-20261025-0674eb82" + }, + { + "field_id": 291, + "value": 0 + }, + { + "field_id": 292, + "value": false + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [59] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [59] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "expert", + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 403, + "request": { + "ids": [59] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 401, + "request": { + "ids": [59] + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [59] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "solution_version": 4, + "is_submitted": false, + "is_archived": false, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [] + } + ], + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/42/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [59], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "solution_version": 5, + "archived_at": "2026-10-25T15:16:07.423984Z" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/42/management/solutions/archive/apply/", + "status": 409, + "request": { + "ids": [59], + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/42/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [59] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "solution_version": 5, + "is_submitted": false, + "is_archived": true, + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [ + { + "path": "ids", + "code": "solution_archived", + "detail": "Решение уже снято с участия.", + "field_id": null + } + ] + } + ], + "score_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": false, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": null, + "response": { + "id": 59, + "program_id": 42, + "version": 5, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:07.423984Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:07.423984Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 289, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "program_id": 42, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 289 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 290 + }, + { + "value": 0, + "field_id": 291 + }, + { + "value": false, + "field_id": 292 + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/42/solutions/59/", + "status": 200, + "request": null, + "response": { + "id": 59, + "program_id": 42, + "version": 5, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain_id": 9, + "created_at": "2026-10-25T15:16:05.951934Z", + "updated_at": "2026-10-25T15:16:07.423984Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:07.423984Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 289, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "program_id": 42, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 289 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 290 + }, + { + "value": 0, + "field_id": 291 + }, + { + "value": false, + "field_id": 292 + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T15:13:49.124223Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/63/", + "status": 200, + "request": null, + "response": { + "id": 63, + "version": 2, + "program": { + "id": 42, + "name": "Архив решений public qa01arc-20261025-0674eb82" + }, + "solution": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T15:16:06.188824Z", + "updated_at": "2026-10-25T15:16:06.326385Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/42/management/solutions/59/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 8, + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:16:07.432073Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:16:07.423984Z", + "reason": "detached", + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/42/management/solutions/59/history/8/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 8, + "application": { + "kind": "solution", + "program_id": 42, + "solution_id": 59, + "name": "Работа captain public qa01arc-20261025-0674eb82" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T15:16:07.432073Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T15:16:07.423984Z", + "reason": "detached", + "revoked_invitation_count": 0 + }, + "before_snapshot": null, + "after_snapshot": null, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 289, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 290, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 291, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 292, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "template": "case_championship", + "program_id": 42, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "answers": [ + { + "value": "Город", + "field_id": 289 + }, + { + "value": "Сохраненные материалы qa01arc-20261025-0674eb82", + "field_id": 290 + }, + { + "value": 0, + "field_id": 291 + }, + { + "value": false, + "field_id": 292 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T15:16:05.954977Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T15:16:06.322271Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "captured_at": "2026-10-25T15:16:07.423984Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/management/solutions/59/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/42/solutions/59/", + "status": 409, + "request": { + "expected_version": 5, + "expected_form_revision": "9930aeb51cb91ad9273a6f2954c2979a2cee4f27579609a971a31e7bde7cea94", + "name": "Недоступная правка" + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/42/solutions/", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/42/solutions/?participation=archived", + "status": 200, + "request": null, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 59, + "program_id": 42, + "name": "Работа captain public qa01arc-20261025-0674eb82", + "captain": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "accepted_count": 2, + "case_value": "Город", + "status": "draft", + "submitted_at": null, + "participation": { + "status": "archived", + "archived_at": "2026-10-25T15:16:07.423984Z", + "reason": "detached" + } + } + ] + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/42/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 42, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": false, + "submission_open": true, + "submission_deadline": "2026-10-26T15:13:49.124223Z", + "can_create": false, + "blocked_reason": "solution_creation_disabled", + "current_application": null + }, + "cacheControl": null + }, + { + "actor": null, + "method": "GET", + "path": "/programs/42/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 42, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "actor": null, + "method": "GET", + "path": "/programs/42/published-solutions/59/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/34/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 34, + "storage_mode": "legacy_project", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "legacy_project", + "program_id": 34, + "project_id": 4, + "program_link_id": 3, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен" + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/34/management/solutions/archive/preview/", + "status": 409, + "request": { + "ids": [55] + }, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/40/management/solutions/archive/preview/", + "status": 404, + "request": { + "ids": [57] + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + } +] diff --git a/docs/qa/program-solutions-archive-20261025/migration-evidence.json b/docs/qa/program-solutions-archive-20261025/migration-evidence.json new file mode 100644 index 00000000..f97d279e --- /dev/null +++ b/docs/qa/program-solutions-archive-20261025/migration-evidence.json @@ -0,0 +1,37 @@ +{ + "status": "PASS", + "migration": "0037_solution_archive_retention", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a", + "hashAfter": "fc500f1028354a9e8697edd516762157f9907574c4dfc4467560c21d8d0cbe0a", + "rowCounts": { + "partner_programs.PartnerProgram": 37, + "partner_programs.PartnerProgramField": 268, + "partner_programs.PartnerProgramUserProfile": 106, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 53, + "partner_programs.ProgramSolutionAnswer": 255, + "partner_programs.ProgramSolutionMember": 71, + "partner_programs.SolutionInvitation": 34, + "project_rates.SolutionScore": 32, + "project_rates.SolutionExpertAssignment": 20, + "project_rates.Criteria": 64, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 25, + "partner_programs.PartnerProgram_managers": 41, + "partner_programs.SolutionAdministrativeChange": 4 + }, + "existingAdminEventsPreserved": 4, + "existingSolutionsArchivedOrDeleted": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true +} diff --git a/docs/qa/program-solutions-archived-rating-20261025/README.md b/docs/qa/program-solutions-archived-rating-20261025/README.md new file mode 100644 index 00000000..684d6590 --- /dev/null +++ b/docs/qa/program-solutions-archived-rating-20261025/README.md @@ -0,0 +1,30 @@ + + +# QA01: архивирование решения во время оценки + +25 октября 2026 года. Сервер явно запрещает оценивание архивной работы до проверки критериев/квоты/назначения. Это исправление поведения внутри согласованного D01, а не завершение первого выпуска. + +Триггер: эксперт одновременно организатор своей программы, открыл оценку; работа архивирована до подтверждения. До исправления сервер возвращал max_rates_reached/409 в открытом режиме либо not_assigned/403 в распределенном, а явный solution_archived оставлял старую форму на фронте. Теперь POST дает solution_archived/409; материалы, оценочные controls и предыдущий ввод исчезают. Эксперт без сохраненного доступа по-прежнему получает404. Сохраненные значения не переписываются. + +Product [BE 394bf166](https://github.com/PROCOLLAB-github/api/commit/394bf1666825ea87e7985b88c401a4e3c9f8d316) / [FE b4191a8b](https://github.com/PROCOLLAB-github/procollab/commit/b4191a8b082e41b318c67c7605fa2764b647c46c). C00 1.20.1:156 схем/59 операций/299 независимых сценариев; все156 прежних схем/59 операций/297 сценариев неизменны, добавлены2 примера. Три shared файла byte-identical. Миграций нет. + +## Доказательства + +- До fix: native3 —2 FAIL/1 PASS; component10 —1 FAIL/9 PASS. После:420 native PostgreSQL tests/OK без skips,2949 frontend tests/484 files +strict types, development build, scoped ESLint, Black/Flake8/Django check/migration consistency/C00/diff PASS. Focused component/wire21 — subset, не добавлять к2949. Locked Prettier3.8.3 до report:2042 файлов PASS; итог с report:2047 файлов PASS. +- Настоящий Chrome 156.0.8078.47/login, обе формы в одном завершенном запуске: КЧ47 с распределением и хакатон48 с открытым оцениванием, работы64/65.0/False/сохраненные комментарии → локальная2/новый комментарий → вкладка команды сохраняет ввод → подтверждение → archive preview/apply настоящим API → rating POST409/solution_archived → старые поля/форма/команда/ввод убраны → retry GET404. Активные собственные pending/completed counters0/0. Проверены390/1440 без horizontal overflow;8 screenshots,2 просмотрены. Network responses не подменялись; page errors в завершенном запуске0. +-36 ответов/20 JSON bodies проверены C00:18 seed +12 browser +6 readonly verifier. Второй эксперт GET/POST404, капитан читает архив readonly. По каждой работе сохранены все4 ID/value пары (0,False,непустой и системный пустой комментарий), полные hashes scores/answers/members и submission snapshot; попытка записать2 не сохранилась. Назначения сняты только явным архивом. Flags новых программ=False, новые Project не создавались. +-25 прежних моделей, включая Auth User и все записи BE08C2, совпали с baseline `c8196d411a2bbab6838167141baa1ae2b2229df7191f29d97f0d37f66cc56989`. Все7 независимых audit headers и прежние6 C2 events сохранены. Отдельный post-recovery check подтвердил прежний hash23 моделей717c9a66… и состояние C2 до новых fixtures. Новый UI организатора/навигация архива этим checkpoint не добавлялись. + +[Browser checks](browser-checks.json), [domain/C00](domain-and-contract-evidence.json), [wire](http-wire.json), [refs/source/raw hashes](evidence.json). Signed proof4 occurrences скрыты только в Git wire после проверки; JWT/Authorization/password не записывались. Private credentials/manifest находятся в TEMP, в Git не включены. + +## Сохраненные ограничения и попытки + +Первый полный native420 прерван локальными I/O ошибками: postgres перешел в recovery,77 errors. Это не PASS. Также Python не прочитал charmap.py, а первый build copy получил UNKNOWN при чтении chunk. PostgreSQL восстановился самостоятельно, без ручного перезапуска или сброса main DB. После восстановления проверены hashes/все прежние C2 данные. Причина I/O не установлена. Упавшая test_procollab_ci_unicode сохранена; итоговые420 прошли отдельно в новой test_procollab_ci_archived_rating_unicode (ICU ru-RU), без параллельного тяжелого FE прогона. FE2949 завершился успешно. + +Focused65 после guard выявил неверный формат тестовой stale revision; input заменен на валидный64-character hash, проверка archive409 сохранена. Первый fixture setup был атомарно отменен валидатором кейса без show_filter, затем helper исправлен без ослабления product validation. Первая попытка запуска браузера не нашла cached executable и не открыла страницу; итоговый запуск использует установленный Chrome. Readonly verifier сначала ожидал3 оценки, затем сравнен с реальным frame: сохранены все4, включая штатный пустой комментарий. Критерии/оценки/работы ради PASS не сбрасывались. Raw failures/logs/helpers и частичная первая build copy сохранены локально. + +Raw: `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-archived-rating`. Build copy меняет только API/WS origins одного generated chunk; source environments не менялись. Remote avatars блокировались: screenshots могут содержать broken avatar. Полные desktop/a11y/zoom/production CDN/binary-file сценарии не заявляются. Owned API8877/SPA8882 остановлены, прежние8769/8871/8876 не перезапускались. Seed/browser helpers повторно не запускать: новые работы уже архивны. Readonly verifier можно использовать для сверки, не выдавая повтор за новый путь. + +Original26 AC strings сохранены;23 PASS — AC01–17/20/22–26, AC18/19 OPEN, AC21 отдельно BE10. Общий expert/results/public path AC18 еще не доказан. D02/D03 approval, manager/reminder/actions/results/public screens, confirmation/history/archive navigation, competitive unsent drafts и полный QA01/REL01 остаются в первом выпуске. Новых merge/deploy/реальных переключений/BE10 не было. + +Preceding C2 f22ea47: Linux PG40212381737 completed/success,39 locking +1818 full/OK без skips; его General40212377825 еще in_progress при упаковке. Preceding C1 bf5fc5a General40208630839 terminal success,1768/OK(skipped67). Это отдельные результаты предыдущих refs. Новый HEAD Linux CI фиксируется после push; PASS ему этим отчетом не присваивается. diff --git a/docs/qa/program-solutions-archived-rating-20261025/browser-checks.json b/docs/qa/program-solutions-archived-rating-20261025/browser-checks.json new file mode 100644 index 00000000..9b387f9f --- /dev/null +++ b/docs/qa/program-solutions-archived-rating-20261025/browser-checks.json @@ -0,0 +1,47 @@ +{ + "status": "PASS", + "checks": [ + { + "program": 47, + "solution": 64, + "template": "case_championship", + "distributed": true, + "status": "PASS", + "submittedScoreAfterArchive": 409, + "code": "solution_archived", + "retry": 404, + "fieldsAndControlsRemoved": true, + "noUnsavedCopy": true, + "teamTabRetainedInput": true, + "activeExpertCounts": { + "pending": 0, + "completed": 0 + }, + "widths": [390, 1440] + }, + { + "program": 48, + "solution": 65, + "template": "hackathon", + "distributed": false, + "status": "PASS", + "submittedScoreAfterArchive": 409, + "code": "solution_archived", + "retry": 404, + "fieldsAndControlsRemoved": true, + "noUnsavedCopy": true, + "teamTabRetainedInput": true, + "activeExpertCounts": { + "pending": 0, + "completed": 0 + }, + "widths": [390, 1440] + } + ], + "javascriptErrors": [], + "oneInvocation": true, + "realLogin": true, + "networkResponsesMocked": false, + "browserVersion": "156.0.8078.47", + "channel": "chrome" +} diff --git a/docs/qa/program-solutions-archived-rating-20261025/domain-and-contract-evidence.json b/docs/qa/program-solutions-archived-rating-20261025/domain-and-contract-evidence.json new file mode 100644 index 00000000..66fd1131 --- /dev/null +++ b/docs/qa/program-solutions-archived-rating-20261025/domain-and-contract-evidence.json @@ -0,0 +1,33 @@ +{ + "status": "PASS", + "contract": "1.20.1", + "responses": 36, + "validRequests": 20, + "seedResponses": 18, + "browserResponses": 12, + "readOnlyVerifierResponses": 6, + "oldModelsUnchanged": 25, + "hashBefore": "c8196d411a2bbab6838167141baa1ae2b2229df7191f29d97f0d37f66cc56989", + "hashAfter": "c8196d411a2bbab6838167141baa1ae2b2229df7191f29d97f0d37f66cc56989", + "programs": [ + { + "id": 47, + "solution": 64, + "template": "case_championship", + "retainedOwnScores": 4 + }, + { + "id": 48, + "solution": 65, + "template": "hackathon", + "retainedOwnScores": 4 + } + ], + "globalRegistrationAuditsRetained": 7, + "unchangedAllEarlierC2Rows": true, + "creationFlagsFalse": true, + "savedZeroFalseAndCommentsRetained": true, + "defaultEmptyCommentPreserved": true, + "attemptedTwoNotWritten": true, + "noNewProject": true +} diff --git a/docs/qa/program-solutions-archived-rating-20261025/evidence.json b/docs/qa/program-solutions-archived-rating-20261025/evidence.json new file mode 100644 index 00000000..206ba470 --- /dev/null +++ b/docs/qa/program-solutions-archived-rating-20261025/evidence.json @@ -0,0 +1,259 @@ +{ + "status": "PASS", + "scope": "D01 archived-rating behavior fix; full first release incomplete", + "backendProduct": "394bf1666825ea87e7985b88c401a4e3c9f8d316", + "frontendProduct": "b4191a8b082e41b318c67c7605fa2764b647c46c", + "contract": { + "version": "1.20.1", + "schemas": 156, + "operations": 59, + "scenarios": 299, + "oldSchemasUnchanged": 156, + "oldOperationsUnchanged": 59, + "oldScenariosUnchanged": 297, + "sharedFilesByteIdentical": 3 + }, + "tests": { + "nativePG": 420, + "newNative": 3, + "frontend": 2949, + "frontendFiles": 484, + "componentAndWireFocused": 21, + "strictTypes": "PASS", + "developmentBuild": "PASS", + "scopedLint": "PASS", + "black": "PASS", + "flake8": "PASS", + "djangoCheck": "PASS", + "migrationConsistency": "PASS", + "prettierBeforeReports": { + "version": "3.8.3", + "files": 2042, + "status": "PASS" + }, + "prettierWithReports": { + "version": "3.8.3", + "files": 2047, + "status": "PASS" + } + }, + "domain": { + "status": "PASS", + "contract": "1.20.1", + "responses": 36, + "validRequests": 20, + "seedResponses": 18, + "browserResponses": 12, + "readOnlyVerifierResponses": 6, + "oldModelsUnchanged": 25, + "hashBefore": "c8196d411a2bbab6838167141baa1ae2b2229df7191f29d97f0d37f66cc56989", + "hashAfter": "c8196d411a2bbab6838167141baa1ae2b2229df7191f29d97f0d37f66cc56989", + "programs": [ + { + "id": 47, + "solution": 64, + "template": "case_championship", + "retainedOwnScores": 4 + }, + { + "id": 48, + "solution": 65, + "template": "hackathon", + "retainedOwnScores": 4 + } + ], + "globalRegistrationAuditsRetained": 7, + "unchangedAllEarlierC2Rows": true, + "creationFlagsFalse": true, + "savedZeroFalseAndCommentsRetained": true, + "defaultEmptyCommentPreserved": true, + "attemptedTwoNotWritten": true, + "noNewProject": true + }, + "browser": { + "status": "PASS", + "checks": [ + { + "program": 47, + "solution": 64, + "template": "case_championship", + "distributed": true, + "status": "PASS", + "submittedScoreAfterArchive": 409, + "code": "solution_archived", + "retry": 404, + "fieldsAndControlsRemoved": true, + "noUnsavedCopy": true, + "teamTabRetainedInput": true, + "activeExpertCounts": { + "pending": 0, + "completed": 0 + }, + "widths": [390, 1440] + }, + { + "program": 48, + "solution": 65, + "template": "hackathon", + "distributed": false, + "status": "PASS", + "submittedScoreAfterArchive": 409, + "code": "solution_archived", + "retry": 404, + "fieldsAndControlsRemoved": true, + "noUnsavedCopy": true, + "teamTabRetainedInput": true, + "activeExpertCounts": { + "pending": 0, + "completed": 0 + }, + "widths": [390, 1440] + } + ], + "javascriptErrors": [], + "oneInvocation": true, + "realLogin": true, + "networkResponsesMocked": false, + "browserVersion": "156.0.8078.47", + "channel": "chrome" + }, + "recovery": { + "status": "PASS", + "oldModelsUnchanged": 23, + "hashBefore": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "hashAfter": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "c2Audits": 7, + "c2SolutionEvents": 6, + "c2Solutions": [60, 61, 62, 63], + "postgresRecoveredWithoutRestart": true + }, + "sourceHashesNormalizedLF": { + "backend": { + "docs/contracts/program-solutions-v1/README.md": "57b80e1f7b670c8d0ebba5e9da167aa46c0640879cf7bf698a2dd7c1fe3216e3", + "docs/contracts/program-solutions-v1/examples.json": "0ffa46ca633c310a87f021321c519254802e496aee8250379c0d5d60d6455a6f", + "docs/contracts/program-solutions-v1/openapi.json": "a796786fa97d25041b4d2418fa729f422c4fa4d52d97a98a5ee1ad63a88fbb75", + "docs/product/program-solutions-be07.md": "33cf9dba0453a3d22d0434844edd8dc0b107d2b62d844e35c8e34004d10400fa", + "project_rates/solution_evaluation.py": "7caad9c059426a707739a0db88dd26c8ae7818ca1a659f61fb6e03b6a552df01", + "project_rates/tests/test_solution_evaluation.py": "89c2c496713c98dbec50ba02c0d67fa4782b00e0b5ef82075f54c3bba8d30766" + }, + "frontend": { + "docs/contracts/program-solutions-v1/README.md": "57b80e1f7b670c8d0ebba5e9da167aa46c0640879cf7bf698a2dd7c1fe3216e3", + "docs/contracts/program-solutions-v1/examples.json": "0ffa46ca633c310a87f021321c519254802e496aee8250379c0d5d60d6455a6f", + "docs/contracts/program-solutions-v1/openapi.json": "a796786fa97d25041b4d2418fa729f422c4fa4d52d97a98a5ee1ad63a88fbb75", + "projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts": "25493c9a849fdd69574a71ccc2d0580d01690642b2237546f3cfd85ad19e757a", + "projects/social_platform/src/app/domain/solution/solution-contract.model.ts": "a7972d2ad9cbd79813700d81c709136cca1289ef6bd9bf06d3b702828e6f5811", + "projects/social_platform/src/app/ui/pages/program/solution-evaluation/solution-evaluation.component.spec.ts": "b658e6b4849eb61d5c733867ff31f97e69404374669fa4cafad935aee581d6bd", + "projects/social_platform/src/app/ui/pages/program/solution-evaluation/solution-evaluation.component.ts": "aec63cc4382476a888507df6a85fe31d4cea8f92a6f8f84df03f1e72798611c3" + } + }, + "rawArtifacts": { + "browser-evidence.json": "fb12ddd8a3e7b3a0c00e6b21771072667621c55bf600fa87baef366dcffa1822", + "browser-failure-1792948080945.json": "7e1851abdd3c066bb3cd513471cd0a9eaf1212545d8be60fe52cb41b3ba8e2b7", + "build-provenance.json": "4b94da2c7e798a3d413930e128f90615fdc3b4b30721e98ed3afbdffa53dc6d3", + "case_championship-archived-1440.png": "fea927fabaa71ec07a6202ff5c4e785de52174dbd078548a8d3225c9fc85c6f9", + "case_championship-archived-390.png": "c214f3b5c6daa033188c91eeb088f897bc95bc2727a2a2cecaeafdad3c0c6364", + "case_championship-before-confirm-1440.png": "7351376e9e1a79e829240d51f97437c7eb5b44a985641a53950e8c71bbeadf58", + "case_championship-before-confirm-390.png": "b57aafa39084f4da588658e4c144a62421971a868cfdc82fa681fa0cc29f408f", + "domain-and-contract-evidence.json": "11d4e32dcd59de72214b333dbcd46d626356b88be4d7e1c694c595939aa71e27", + "hackathon-archived-1440.png": "fc3de347668d0f8f0fcba4357e5eb85fdd139ebc0fd508804c3466d9f3b248f1", + "hackathon-archived-390.png": "2908c9c8bdf78dc2b2f6167fd677cf3a8220e888ac009fc49bdd791fb658aa57", + "hackathon-before-confirm-1440.png": "c18d073948a38b5e653e1343eb06b4112c7c428cac820d8cbf50537bcd48e48d", + "hackathon-before-confirm-390.png": "1edaf1ecada51e8c26fe3b1fdd82a644ea69f56a62c61bb861c048311f6bc5fa", + "http-wire.json": "b7a6ba74ca06fad2cdd39e8def686258eb653a43ea6fc2722d920d0f0f1a5f36", + "procollab-archived-rating-browser-chrome.log": "d0bb975d7ec253cf1c3958f7479e9c0cf9b5ab2e6fb724c371e20e18a040113e", + "procollab-archived-rating-browser.cjs": "5e59591f0509404e3d143367574b180db4cec17fe77bf0ee1bb8a55ef6063b75", + "procollab-archived-rating-browser.log": "f9f3a83279317430cfae03ffe882848033c0eba9d8fcc77ccff69f7927be167d", + "procollab-archived-rating-build-copy-recovered.log": "8ae0abe2c9bc4b7bc6bb726ace35816e9c803f1fc6fcfaa951f29a6fb685aa51", + "procollab-archived-rating-build.cjs": "136b7c266e3a1d7bfc060a5077af302f6f5a850d085e9a34665e405b49aaf33c", + "procollab-archived-rating-build.log": "824f869360907ad73c864c56445cd9fc0eaafde11b7684d0b8b0bfc7da281a93", + "procollab-archived-rating-contract.py": "8f7d3a88b313dfff7b6bddf0128b96937e8ccfda454d6e3bad911439ad208b0c", + "procollab-archived-rating-fe-final.log": "05228d50f42bdb1ed74bac8eb5faf4377e50dfebe2bf13ee70e1a88fa24713b0", + "procollab-archived-rating-format-before-reports.json": "7b9282c7f10eef7761182843bc43761099da5080beb561f7299ab1fa3954b9b7", + "procollab-archived-rating-main-recovery.json": "6969115b676e524d0f4ff9fb554e821c08206c43b17041310ff9afcc91a9264d", + "procollab-archived-rating-native-after.log": "f1d328be6825fe95a8f01267386a154980b21e6462c1d0e10eb2a389ffaccdc0", + "procollab-archived-rating-native-before.log": "00c462b1c8ff061e938bfeff6929007c7f07de51d4a35aeade9cc857955800b8", + "procollab-archived-rating-native-final.log": "35f441b492bf94978943e285750a69e3937e9049832a851cec79f9b1fc835e03", + "procollab-archived-rating-native-recovered.log": "cd16434e227ab35a0094b4f1e162f6e968deba96416d9bf9792084708cdf3c16", + "procollab-archived-rating-pg-recovery.log": "46d432d365db1bbda5ffd9ce3f46f4b0afb5a39b7e0af4b47ebdc83dc45fb996", + "procollab-archived-rating-preceding-c1-general.log": "31e3da8df467214cd2838877645930033aa767f569e5c7901ca8829a339d4374", + "procollab-archived-rating-preceding-c2-pg.log": "7a3f8b20eb04492889a43cbe1b7e902b4d034f8069f2d6656af4d7f26637863a", + "procollab-archived-rating-recovery-check.py": "6fd9dc9edceea6221b21e81b2521086b485afe20cc38c3b7ccfc204c7313bf0d", + "procollab-archived-rating-seed-final.log": "85d5d3e275f2547046161c148ebcbd6ed62d8743f0e1b9b550497d98c907b549", + "procollab-archived-rating-seed-recovered.log": "55f4e7f60607966410c82a255ff3c9c2686cf033208dcab338c307ef74b0172a", + "procollab-archived-rating-seed.log": "92a16c45f6425312807b5ffaa20619d2bd9346dd94ad1e7297334ac01cc137c0", + "procollab-archived-rating-seed.py": "ff5e7b7b4c5a0b1b40fe70de7d84b96593c24b91790149d08759ee3513172b73", + "procollab-archived-rating-server.py": "2b2edeffed8c144f469726a59db01561f309ddb0c792927cc44a4948a0aa1e27", + "procollab-archived-rating-spa.py": "f9cc77ef44018f7eaf86ff0639be0aa77e80782ef1f8dc8872a1537e54734b4b", + "procollab-archived-rating-ui-after.log": "a00c0ef364598480e32a732726e1e871d66342b00e948aa040e330356496d0ac", + "procollab-archived-rating-ui-before.log": "39e4767ee6208ecc9b78422963a0fcbbca03ff70abda59b8289c1c57a8412c01", + "procollab-archived-rating-verify-final.log": "c46c6964eea653209617c78d824202b10435bcc874cd04943848128b2d772d51", + "procollab-archived-rating-verify.log": "34a0315967f4baa64abe1325d58d78a227a567ec2de5d465c1c2180fac9d35dd", + "procollab-archived-rating-verify.py": "3ba98ef966bb8014e2f801c95535a74dea3fa6eb8b52996917d925d264a07e6f", + "procollab-test-archived-rating-unicode.py": "cae96723152683c3b819efafd59621b5c6a7fcfecf3fa39f476082e8024b8d31", + "procollab-archived-rating-format-final.json": "180757ddaa25f8163dc2514ac4faf704f7f6fe32c4ba81066e3f43a629a4fbb1", + "project-count-evidence.json": "95de71492afacde61c6ea6b3c5312571808c794001f69043d76c6b955711a276" + }, + "redactedSignedProofs": 4, + "attempts": [ + "Before: native3 =>2 product failures, component10 =>1 product failure.", + "Intermediate native65: malformed synthetic stale-revision string correctly got400; test now uses valid64-character stale hash and still asserts archive409.", + "Full420 interrupted by local PostgreSQL recovery and I/O errors =>77 errors; not PASS. Separate fresh ICU ru-RU test DB completed420/OK. Failed test DB retained; main DB not reset.", + "First seed after recovery rejected missing show_filter on a case definition and rolled back its atomic fixture setup. Corrected fixture obeys the existing case rule.", + "Initial Playwright launch had no cached executable and performed no browser actions; completed run used installed Chrome.", + "Initial verifier assumed3 scores; actual captured frame includes default empty comment, so all4 exact ID/value pairs plus complete row hashes are now checked. No work, criteria or score was reset." + ], + "qa": { + "originalACRowsUnchanged": 26, + "passCount": 23, + "passAC": "01-17,20,22-26", + "openAC": ["18", "19"], + "separateBE10": "21" + }, + "priorCI": { + "c2PG": { + "run": 40212381737, + "head": "f22ea47bad4ef68b66a239c5787c23607f8a739e", + "status": "completed/success", + "locking": 39, + "full": 1818, + "skips": 0 + }, + "c2General": { + "run": 40212377825, + "status": "in_progress" + }, + "c1General": { + "run": 40208630839, + "status": "completed/success", + "full": 1768, + "skipped": 67 + } + }, + "limitations": [ + "No new layout/HTML/SCSS/DS change or D02/D03 implementation.", + "Completed browser invocation on Chrome156,390/1440; full a11y/zoom/production CDN not claimed. External avatars were blocked, broken avatars may appear in screenshots.", + "Initial local I/O failure root cause not established; automatic PG recovery observed and old data hashes rechecked.", + "Binary/CDN operations not part of this journey.", + "D02/D03 screens, competitive unsent draft eligibility, full QA01/REL01 remain; BE10 separate.", + "No merge/deploy/real-program switching/Project migration or deletion; new HEAD Linux CI not claimed." + ], + "ownedHelpers": { + "stopped": true, + "apiWrapper": 19568, + "apiChild": 11504, + "spaWrapper": 32128, + "spaChild": 24008, + "releasedPorts": [8877, 8882], + "oldServersUnchanged": { + "8769": 20920, + "8871": 19092, + "8876": 33332 + } + }, + "projectCounts": { + "status": "PASS", + "projectCountBefore": 4, + "projectCountAfter": 4, + "newProjects": 0 + } +} diff --git a/docs/qa/program-solutions-archived-rating-20261025/http-wire.json b/docs/qa/program-solutions-archived-rating-20261025/http-wire.json new file mode 100644 index 00000000..b004a92b --- /dev/null +++ b/docs/qa/program-solutions-archived-rating-20261025/http-wire.json @@ -0,0 +1,4248 @@ +[ + { + "role": "captain", + "method": "POST", + "path": "/programs/47/solutions/", + "status": 201, + "request": { + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "response": { + "id": 64, + "program_id": 47, + "version": 1, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.016648Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 47, + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 306, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 306 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 307 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 308 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 309 + } + ] + } + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/47/solutions/64/", + "status": 200, + "request": null, + "response": { + "id": 64, + "program_id": 47, + "version": 1, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.016648Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 47, + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 306, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 306 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 307 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 308 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 309 + } + ] + } + } + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/47/solutions/64/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 30, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 68, + "version": 1, + "program": { + "id": 47, + "name": "Архив и открытая оценка case_championship qa01ar-20261025-2d84767a" + }, + "solution": { + "kind": "solution", + "program_id": 47, + "solution_id": 64, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "captain": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T17:07:40.193334Z", + "updated_at": "2026-10-25T17:07:40.193334Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + } + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/68/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 68, + "version": 2, + "program": { + "id": 47, + "name": "Архив и открытая оценка case_championship qa01ar-20261025-2d84767a" + }, + "solution": { + "kind": "solution", + "program_id": 47, + "solution_id": 64, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "captain": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T17:07:40.193334Z", + "updated_at": "2026-10-25T17:07:40.278679Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 47, + "solution_id": 64, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "solution_version": 3 + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/47/solutions/64/", + "status": 200, + "request": null, + "response": { + "id": 64, + "program_id": 47, + "version": 3, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.280828Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 47, + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 306, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 306 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 307 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 308 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 309 + } + ] + } + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/47/solutions/64/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "answers": [ + { + "field_id": 306, + "value": "Город" + }, + { + "field_id": 307, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 308, + "value": 0 + }, + { + "field_id": 309, + "value": false + } + ] + }, + "response": { + "id": 64, + "program_id": 47, + "version": 4, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.445560Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 47, + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 306, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 306, + "value": "Город" + }, + { + "field_id": 307, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 308, + "value": 0 + }, + { + "field_id": 309, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + } + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/47/solutions/64/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778" + }, + "response": { + "id": 64, + "program_id": 47, + "version": 5, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.551010Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 47, + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 306, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 306, + "value": "Город" + }, + { + "field_id": 307, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 308, + "value": 0 + }, + { + "field_id": 309, + "value": false + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:40.548989Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + } + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/47/solutions/64/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 47, + "name": "Архив и открытая оценка case_championship qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 64, + "program_id": 47, + "version": 5, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.551010Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 306, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "program_id": 47, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 306 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 307 + }, + { + "value": 0, + "field_id": 308 + }, + { + "value": false, + "field_id": 309 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:40.548989Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "criteria": [ + { + "id": 85, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 86, + "name": "Качество 3", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": null + }, + { + "id": 87, + "name": "Проверено 4", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 88, + "name": "Комментарий 5", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "expert", + "method": "POST", + "path": "/programs/47/solutions/64/evaluation/", + "status": 200, + "request": { + "expected_criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "scores": [ + { + "criterion_id": 86, + "value": "0" + }, + { + "criterion_id": 87, + "value": "False" + }, + { + "criterion_id": 88, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ] + }, + "response": { + "program": { + "id": 47, + "name": "Архив и открытая оценка case_championship qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 64, + "program_id": 47, + "version": 5, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.551010Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 306, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "program_id": 47, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 306 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 307 + }, + { + "value": 0, + "field_id": 308 + }, + { + "value": false, + "field_id": 309 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:40.548989Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "criteria": [ + { + "id": 85, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 86, + "name": "Качество 3", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 87, + "name": "Проверено 4", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 88, + "name": "Комментарий 5", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/48/solutions/", + "status": 201, + "request": { + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "response": { + "id": 65, + "program_id": 48, + "version": 1, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.076815Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 48, + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 310, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 310 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 311 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 312 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 313 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 314 + } + ] + } + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/48/solutions/65/", + "status": 200, + "request": null, + "response": { + "id": 65, + "program_id": 48, + "version": 1, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.076815Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 48, + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 310, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 310 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 311 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 312 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 313 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 314 + } + ] + } + } + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/48/solutions/65/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 30, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 69, + "version": 1, + "program": { + "id": 48, + "name": "Архив и открытая оценка hackathon qa01ar-20261025-2d84767a" + }, + "solution": { + "kind": "solution", + "program_id": 48, + "solution_id": 65, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "captain": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T17:07:41.292822Z", + "updated_at": "2026-10-25T17:07:41.292822Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + } + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/69/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 69, + "version": 2, + "program": { + "id": 48, + "name": "Архив и открытая оценка hackathon qa01ar-20261025-2d84767a" + }, + "solution": { + "kind": "solution", + "program_id": 48, + "solution_id": 65, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "captain": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T17:07:41.292822Z", + "updated_at": "2026-10-25T17:07:41.384597Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 48, + "solution_id": 65, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "solution_version": 3 + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/48/solutions/65/", + "status": 200, + "request": null, + "response": { + "id": 65, + "program_id": 48, + "version": 3, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.388626Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 48, + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 310, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 310 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 311 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 312 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 313 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 314 + } + ] + } + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/48/solutions/65/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "answers": [ + { + "field_id": 310, + "value": "Город" + }, + { + "field_id": 311, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 312, + "value": 0 + }, + { + "field_id": 313, + "value": false + }, + { + "field_id": 314, + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a" + } + ] + }, + "response": { + "id": 65, + "program_id": 48, + "version": 4, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.571272Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 48, + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 310, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 310, + "value": "Город" + }, + { + "field_id": 311, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 312, + "value": 0 + }, + { + "field_id": 313, + "value": false + }, + { + "field_id": 314, + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + } + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/48/solutions/65/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b" + }, + "response": { + "id": 65, + "program_id": 48, + "version": 5, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.685460Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 29, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true + }, + { + "user": { + "id": 30, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 48, + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 310, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 310, + "value": "Город" + }, + { + "field_id": 311, + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a" + }, + { + "field_id": 312, + "value": 0 + }, + { + "field_id": 313, + "value": false + }, + { + "field_id": 314, + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a" + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:41.682257Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + } + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/48/solutions/65/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 48, + "name": "Архив и открытая оценка hackathon qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 65, + "program_id": 48, + "version": 5, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.685460Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 310, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "program_id": 48, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 310 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 311 + }, + { + "value": 0, + "field_id": 312 + }, + { + "value": false, + "field_id": 313 + }, + { + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a", + "field_id": 314 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:41.682257Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "criteria": [ + { + "id": 89, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 90, + "name": "Качество 6", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": null + }, + { + "id": 91, + "name": "Проверено 7", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 92, + "name": "Комментарий 8", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "expert", + "method": "POST", + "path": "/programs/48/solutions/65/evaluation/", + "status": 200, + "request": { + "expected_criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "scores": [ + { + "criterion_id": 90, + "value": "0" + }, + { + "criterion_id": 91, + "value": "False" + }, + { + "criterion_id": 92, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ] + }, + "response": { + "program": { + "id": 48, + "name": "Архив и открытая оценка hackathon qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 65, + "program_id": 48, + "version": 5, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.685460Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 310, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "program_id": 48, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 310 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 311 + }, + { + "value": 0, + "field_id": 312 + }, + { + "value": false, + "field_id": 313 + }, + { + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a", + "field_id": 314 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:41.682257Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "criteria": [ + { + "id": 89, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 90, + "name": "Качество 6", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 91, + "name": "Проверено 7", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 92, + "name": "Комментарий 8", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "browser-expert", + "method": "GET", + "path": "/programs/47/solutions/64/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 47, + "name": "Архив и открытая оценка case_championship qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 64, + "program_id": 47, + "version": 5, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:07:40.551010Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 306, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "program_id": 47, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 306 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 307 + }, + { + "value": 0, + "field_id": 308 + }, + { + "value": false, + "field_id": 309 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:40.548989Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "criteria": [ + { + "id": 85, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 86, + "name": "Качество 3", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 87, + "name": "Проверено 4", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 88, + "name": "Комментарий 5", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "dual-role-expert-manager", + "method": "POST", + "path": "/programs/47/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [64] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 47, + "solution_id": 64, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "solution_version": 5, + "is_submitted": true, + "is_archived": false, + "score_count": 4, + "removed_assignments_count": 1, + "revoked_invitation_count": 0, + "errors": [] + } + ], + "score_count": 4, + "removed_assignments_count": 1, + "revoked_invitation_count": 0, + "can_apply": true, + "preview_token": "" + } + }, + { + "role": "dual-role-expert-manager", + "method": "POST", + "path": "/programs/47/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [64], + "preview_token": "" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 47, + "solution_id": 64, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a" + }, + "solution_version": 6, + "archived_at": "2026-10-25T17:09:11.219357Z" + } + ] + } + }, + { + "role": "browser-expert", + "method": "POST", + "path": "/programs/47/solutions/64/evaluation/", + "status": 409, + "request": { + "expected_criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "scores": [ + { + "criterion_id": 85, + "value": "" + }, + { + "criterion_id": 86, + "value": "2" + }, + { + "criterion_id": 87, + "value": "False" + }, + { + "criterion_id": 88, + "value": "Несохраненная оценка qa01ar-20261025-2d84767a" + } + ] + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + } + }, + { + "role": "browser-expert", + "method": "GET", + "path": "/programs/47/solutions/64/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "dual-role-expert-manager", + "method": "GET", + "path": "/programs/47/solution-evaluation/?status=completed", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [], + "evaluation_counts": { + "pending": 0, + "completed": 0 + }, + "case_options": ["Город", "Образование"] + } + }, + { + "role": "browser-expert", + "method": "GET", + "path": "/programs/48/solutions/65/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 48, + "name": "Архив и открытая оценка hackathon qa01ar-20261025-2d84767a" + }, + "solution": { + "id": 65, + "program_id": 48, + "version": 5, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:07:41.685460Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 310, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "program_id": 48, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 310 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 311 + }, + { + "value": 0, + "field_id": 312 + }, + { + "value": false, + "field_id": 313 + }, + { + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a", + "field_id": 314 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:41.682257Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "criteria": [ + { + "id": 89, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 90, + "name": "Качество 6", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0" + }, + { + "id": 91, + "name": "Проверено 7", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 92, + "name": "Комментарий 8", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "Сохраненный комментарий qa01ar-20261025-2d84767a" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 2, + "required_evaluators": 2, + "deadline": "2026-10-27T17:07:39.820702Z" + } + }, + { + "role": "dual-role-expert-manager", + "method": "POST", + "path": "/programs/48/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [65] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 48, + "solution_id": 65, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "solution_version": 5, + "is_submitted": true, + "is_archived": false, + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "errors": [] + } + ], + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "can_apply": true, + "preview_token": "" + } + }, + { + "role": "dual-role-expert-manager", + "method": "POST", + "path": "/programs/48/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [65], + "preview_token": "" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 48, + "solution_id": 65, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a" + }, + "solution_version": 6, + "archived_at": "2026-10-25T17:09:18.327974Z" + } + ] + } + }, + { + "role": "browser-expert", + "method": "POST", + "path": "/programs/48/solutions/65/evaluation/", + "status": 409, + "request": { + "expected_criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "scores": [ + { + "criterion_id": 89, + "value": "" + }, + { + "criterion_id": 90, + "value": "2" + }, + { + "criterion_id": 91, + "value": "False" + }, + { + "criterion_id": 92, + "value": "Несохраненная оценка qa01ar-20261025-2d84767a" + } + ] + }, + "response": { + "code": "solution_archived", + "detail": "Решение снято с участия. Материалы доступны для просмотра.", + "errors": [], + "meta": {} + } + }, + { + "role": "browser-expert", + "method": "GET", + "path": "/programs/48/solutions/65/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "dual-role-expert-manager", + "method": "GET", + "path": "/programs/48/solution-evaluation/?status=completed", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [], + "evaluation_counts": { + "pending": 0, + "completed": 0 + }, + "case_options": ["Город", "Образование"] + } + }, + { + "role": "other", + "method": "GET", + "path": "/programs/47/solutions/64/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "other", + "method": "POST", + "path": "/programs/47/solutions/64/evaluation/", + "status": 404, + "request": { + "expected_criteria_revision": "4e05c1aa16267989a173957781f526689a67669fc52c26790ae62fc771ce74f4", + "scores": [ + { + "criterion_id": 86, + "value": "2" + }, + { + "criterion_id": 87, + "value": "False" + }, + { + "criterion_id": 88, + "value": "Запрещенная запись" + } + ] + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/47/solutions/64/", + "status": 200, + "request": null, + "response": { + "id": 64, + "program_id": 47, + "version": 6, + "name": "Работа для оценки case_championship qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:40.016648Z", + "updated_at": "2026-10-25T17:09:11.219357Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T17:09:11.219357Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.021769Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:40.273637Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 306, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 307, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 308, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 309, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "75df2b206fd3312e54b034f8d9f032e97092e98b12d8af688a699f676600a778", + "template": "case_championship", + "program_id": 47, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 306 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 307 + }, + { + "value": 0, + "field_id": 308 + }, + { + "value": false, + "field_id": 309 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:40.548989Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + } + }, + { + "role": "other", + "method": "GET", + "path": "/programs/48/solutions/65/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "other", + "method": "POST", + "path": "/programs/48/solutions/65/evaluation/", + "status": 404, + "request": { + "expected_criteria_revision": "63e6f2a731b85b50a8ca1fb6a479c725e925135f7d0644f67835a39a91fe12ac", + "scores": [ + { + "criterion_id": 90, + "value": "2" + }, + { + "criterion_id": 91, + "value": "False" + }, + { + "criterion_id": 92, + "value": "Запрещенная запись" + } + ] + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + } + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/48/solutions/65/", + "status": 200, + "request": null, + "response": { + "id": 65, + "program_id": 48, + "version": 6, + "name": "Работа для оценки hackathon qa01ar-20261025-2d84767a", + "captain_id": 29, + "created_at": "2026-10-25T17:07:41.076815Z", + "updated_at": "2026-10-25T17:09:18.327974Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T17:09:18.327974Z", + "reason": "detached" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 29, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.081475Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 30, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T17:07:41.381325Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 310, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 311, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 312, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 313, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 314, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "2c2c11723594f7d313c4e646de339469ae81326fb7149fa403e55a5b6fa7a56b", + "template": "hackathon", + "program_id": 48, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 310 + }, + { + "value": "Материалы архивной проверки qa01ar-20261025-2d84767a", + "field_id": 311 + }, + { + "value": 0, + "field_id": 312 + }, + { + "value": false, + "field_id": 313 + }, + { + "value": "https://example.com/archived-rating/qa01ar-20261025-2d84767a", + "field_id": 314 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T17:07:39.820702Z", + "submitted_at": "2026-10-25T17:07:41.682257Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + } + } +] diff --git a/docs/qa/program-solutions-case-actions-20261025/README.md b/docs/qa/program-solutions-case-actions-20261025/README.md new file mode 100644 index 00000000..05a5707b --- /dev/null +++ b/docs/qa/program-solutions-case-actions-20261025/README.md @@ -0,0 +1,30 @@ +# QA01: подтвержденная смена кейса решения + +Реализован API существующего организаторского сценария для несданного решения: preview → подписанное подтверждение → точная смена case answer. Материалы, состав и сданные snapshot сохраняются. [Требования CASE01–CASE08](../../product/program-solutions-be08-case-actions.md). Это API часть BE08/FE08, не готовность всего первого выпуска или нового manager UI. + +C00 1.15.0:128 schemas/49 operations/209 independent scenarios. Все прежние125/47/185 структурно сохранены, включая submit action enum и ошибки. Три contract файла byte-identical; [compatibility](contract-compatibility.json). Frontend generated DTO/port/repository/adapter/2 use cases и31 новых wire tests с настоящим CamelcaseInterceptor; strict types запрещают missing token и Project references. + +| AC | Evidence и результат | +| --- | --- | +| CASE01 | API tests manager/staff/captain/member/expert/foreign manager/foreign IDs/anonymous/mode/input; actual HTTP401/403 и private/no-store. Unsafe IDs, unknown body/query, non-string case/token отклонены. | +| CASE02 | Read-only preview hash, SolutionRef/field/current/target/version; same/submitted/unexpected score или assignment дают can_apply=false/token=null. | +| CASE03 | Actual КЧ/хакатон/noncompetitive: case→case и null→case, точные leading/trailing spaces и pipe. Меняются case answer/version/time; 0/false, name/captain, accepted roster и pending invitation неизменны. | +| CASE04 | Signature/expiry/actor/solution/target/state и replay проверены tests; изменения answer/member/form/name без version bump вызывают conflict. Реальный captain PATCH делает старый token неприменимым. Program scope проверяется foreign IDs и decoded proof context. | +| CASE05 | Frozen preview/apply не меняют submitted snapshot и настоящие score/assignment records в tests; два actual submitted anchors сохранены. Непредвиденные draft evaluation records не удаляются. Noncompetitive и creation=False/closed deadline поддерживают существующий draft case action. | +| CASE06 | Три PostgreSQL races: confirm×confirm, confirm×captain PATCH, confirm×submit. Оба worker реально ожидают program row lock по pg_blocking_pids до снятия контроллера. Одна ordered version; frozen snapshot не меняется. | +| CASE07 |31 wire tests покрывают все24 новых независимых сценария, safe ID bounds, exact strings и opaque token; вместе с прежними30 submit tests61 PASS. Final frontend2827/480+strict types/build/scoped lint PASS. | +| CASE08 | [82 первичных HTTP responses](http-evidence.json) и [33 post-review requests](domain-and-contract-evidence.json), все115 responses проверены C00;88 schema-valid JSON requests,15 намеренно неверных типов отклонены400. Прежние строки15 выбранных domain моделей/Project неизменны; verifier не пишет domain data. | + +Все CASE gates PASS в описанном API scope. Broad local PostgreSQL regression:356 tests, включая все solution modules/оценивание и прежние management actions/removal/services, PASS. После финального строгого string-token guard и добавления submit race выполнены52 tests:19 новых case tests и прежние submit/legacy actions, PASS. Не записывать356 как повторенный прогон после последней правки. Black/Flake8, Django check, migration consistency/C00 PASS; новых migrations нет. Новый commit проходит собственный CI после push; зеленый ad3945e относится к предыдущему source. + +## Среда и происхождение + +Новые synthetic программы30/31/32, решения45/47/49 и anchors46/48/50, prefix qa01c-20261025-2b16a5aa. API8877, PostgreSQL18/main procollab_ci, locmem mail. Backend поверх ad3945e, frontend поверх37ecd71d; runtime source hashes в [manifest](local-artifacts.json). Test DB отдельный test_procollab_ci_unicode. Ни реальные программы, ни production не переключались. Пароли/JWT/подписанные токены не включены в Git; raw wire хранится локально под SHA256. + +Первичный HTTP сценарий выполнил все положительные case writes до последнего type-only guard токена. Service logic осталась byte-identical. API перезапущен;33 current-source requests проверили отказ числовым/boolean/object/list токенам, валидный string-token путь до bound-state проверки, права/private headers и неизменные конечные данные. Этот verifier не повторял case writes и не сбрасывал fixtures. Оба этапа сохранены отдельно. + +Первые локальные test attempts исправили предположения fixture: второго варианта кейса еще не было, экземпляр solution имел старую version, authentication error использует authentication_required. Это не продуктовые ошибки и не основание сбрасывать main fixtures. При source review усилена проверка string-token и добавлено настоящее score/assignment сохранение и confirm×submit race. + +Финально три primary решения остаются drafts с точным новым кейсом,0/false, одним капитаном и одним pending; creation flags=False. Anchors46/48 submitted со старым snapshot,50 остается draft. Прежняя baseline выборка15 моделей сохранена. Current-source verifier не менял домен; auth/session/last_activity не входят в этот hash audit. + +D02/D03, административные исключения уже сданных работ, удаление/отвязка с сохранением материалов, конкурсная публикация и полный QA01/REL01 остаются открытыми. Никакой permanent draft-only замены требуемой политики не утверждалось. Новый Angular screen/route/control/SCSS не добавлялся. Merge/deploy и BE10 не выполнялись. diff --git a/docs/qa/program-solutions-case-actions-20261025/contract-compatibility.json b/docs/qa/program-solutions-case-actions-20261025/contract-compatibility.json new file mode 100644 index 00000000..c759a0e5 --- /dev/null +++ b/docs/qa/program-solutions-case-actions-20261025/contract-compatibility.json @@ -0,0 +1,27 @@ +{ + "status": "PASS", + "version": "1.15.0", + "schemas": 128, + "operations": 49, + "scenarios": 209, + "oldSchemasUnchanged": 125, + "oldOperationsUnchanged": 47, + "oldScenariosUnchanged": 185, + "files": [ + { + "file": "README.md", + "sha256": "b35571eb2e4cb91ff8796c1f832aec96226ffd7aed5c50370bb4d274d6235c5d", + "byteIdentical": true + }, + { + "file": "openapi.json", + "sha256": "9aae7fd93dadfb1c4c153c4f9346b4790c8386227906dfb50fc4454db6fa33ab", + "byteIdentical": true + }, + { + "file": "examples.json", + "sha256": "9501c5dda86b1ad396eb9b0b9f5eaa3344db6c766b230035268451e86b1f7610", + "byteIdentical": true + } + ] +} diff --git a/docs/qa/program-solutions-case-actions-20261025/domain-and-contract-evidence.json b/docs/qa/program-solutions-case-actions-20261025/domain-and-contract-evidence.json new file mode 100644 index 00000000..731c7760 --- /dev/null +++ b/docs/qa/program-solutions-case-actions-20261025/domain-and-contract-evidence.json @@ -0,0 +1,397 @@ +{ + "status": "PASS", + "prefix": "qa01c-20261025-2b16a5aa", + "c00": "1.15.0", + "validation": { + "responses": 115, + "requests": 88, + "rejectedInvalidRequests": 15 + }, + "primaryResponses": 82, + "postReviewRequests": 33, + "programs": [ + { + "program": 30, + "key": "case_championship", + "solution": 45, + "version": 5, + "anchor": 46, + "anchorSubmitted": true, + "caseField": 230, + "caseName": " Образование | наука ", + "accepted": 1, + "pending": 1, + "zeroFalsePreserved": true, + "creationFlagOff": true, + "unchangedSinceAction": true + }, + { + "program": 31, + "key": "hackathon", + "solution": 47, + "version": 5, + "anchor": 48, + "anchorSubmitted": true, + "caseField": 237, + "caseName": " Образование | наука ", + "accepted": 1, + "pending": 1, + "zeroFalsePreserved": true, + "creationFlagOff": true, + "unchangedSinceAction": true + }, + { + "program": 32, + "key": "noncompetitive", + "solution": 49, + "version": 5, + "anchor": 50, + "anchorSubmitted": false, + "caseField": 248, + "caseName": " Образование | наука ", + "accepted": 1, + "pending": 1, + "zeroFalsePreserved": true, + "creationFlagOff": true, + "unchangedSinceAction": true + } + ], + "runtimeSources": { + "partner_programs/services/solution_case_actions.py": "d037738e1aa3428403ea0a4e3b8f097551bf57cbb8839c8fe3d68633ccacb36b", + "partner_programs/solution_case_action_api.py": "6048400a6f2de3e6d520e8a8653995496e1719a7760c454975378f23379ed700", + "partner_programs/urls.py": "f4a1e2cf2dd98056e30d0fd92a7e7b88419d7516c2ff3fbd31f57e39ccf8452c" + }, + "earlierRuntimeSources": { + "partner_programs/services/solution_case_actions.py": "d037738e1aa3428403ea0a4e3b8f097551bf57cbb8839c8fe3d68633ccacb36b", + "partner_programs/solution_case_action_api.py": "0299446f12958653ba8d707ed8d28212954d2fa94828b209ee0732e78104df9a", + "partner_programs/urls.py": "f4a1e2cf2dd98056e30d0fd92a7e7b88419d7516c2ff3fbd31f57e39ccf8452c" + }, + "preexistingSelectedDomainUnchanged": true, + "baselineModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "verificationDomainMutations": 0, + "oldSchemasUnchanged": 125, + "oldOperationsUnchanged": 47, + "oldScenariosUnchanged": 185, + "contractCopiesByteIdentical": true, + "events": [ + { + "role": "captain", + "method": "GET", + "path": "/programs/30/solutions/45/", + "status": 200, + "code": null, + "cacheControl": null, + "responseSha256": "248cca612aba91bae2387b971aea87d489bc8dfa2fe9b0dfdc9d61114445932d" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "b2bb126e60bb4b9de0b6c7baf0fe98e763d041108fb9396260a368fbac3c8c29" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "169a0a68cd7b71880d270e667002b4a6f40d3298d67371c7e657b8a2ff94e32d" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 403, + "code": "permission_denied", + "cacheControl": "private, no-store", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 401, + "code": "authentication_required", + "cacheControl": "private, no-store", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/31/solutions/47/", + "status": 200, + "code": null, + "cacheControl": null, + "responseSha256": "2252a561b419575ab56593e8905e2a214639ec0dbe22a47c15fef3725b181f66" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "b9e81736cab6b5a9e1411ba7a12cadbfae7a019dc4f71ed0be5d47c81a26859d" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "8b7c1cb66ac44b63b72edbbf11810f4a229172c8d3500d140733792a491d15f4" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 403, + "code": "permission_denied", + "cacheControl": "private, no-store", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 401, + "code": "authentication_required", + "cacheControl": "private, no-store", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/32/solutions/49/", + "status": 200, + "code": null, + "cacheControl": null, + "responseSha256": "60ca01c767478f713a3f3c8e9d1871a901c83e765a9b27ed4c3c395498b56fc7" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "f40474ac5b50fbcc5cebec3ad1723ee77f5aa1949d74f2194d06e52cead45b6b" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 400, + "code": "validation_error", + "cacheControl": "private, no-store", + "responseSha256": "0d468d663c6542412e14a4a841d83184fe8fd8b3fd65c0b57ee001a60a116fdb" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "cacheControl": "private, no-store", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "cacheControl": "private, no-store", + "responseSha256": "9cdd345df7351153541d08112fb5a944b87a7c15878d293afd6e9342972ec0df" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 403, + "code": "permission_denied", + "cacheControl": "private, no-store", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 401, + "code": "authentication_required", + "cacheControl": "private, no-store", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + } + ], + "limits": [ + "Primary 82 HTTP responses preceded a final strict-token type guard; service logic is byte-identical. API was restarted, and current-source checks reject non-string tokens and revalidate state/privacy/binding.", + "Old local test-fixture assumptions (second case absent, stale in-memory version, authentication code) were corrected; no product data reset.", + "Current submitted administrative policy remains frozen pending user decision; D02 and destructive removal are not implemented by this case endpoint." + ], + "curation": "Raw post-review request/response bodies retained locally; committed trace uses SHA256 and never includes signed tokens." +} diff --git a/docs/qa/program-solutions-case-actions-20261025/http-evidence.json b/docs/qa/program-solutions-case-actions-20261025/http-evidence.json new file mode 100644 index 00000000..481f5725 --- /dev/null +++ b/docs/qa/program-solutions-case-actions-20261025/http-evidence.json @@ -0,0 +1,839 @@ +{ + "status": "PASS", + "prefix": "qa01c-20261025-2b16a5aa", + "backendHead": "ad3945ea2ebcd214a26b64ab09c92d334f9d2c77", + "runtimeSources": { + "partner_programs/services/solution_case_actions.py": "d037738e1aa3428403ea0a4e3b8f097551bf57cbb8839c8fe3d68633ccacb36b", + "partner_programs/solution_case_action_api.py": "0299446f12958653ba8d707ed8d28212954d2fa94828b209ee0732e78104df9a", + "partner_programs/urls.py": "f4a1e2cf2dd98056e30d0fd92a7e7b88419d7516c2ff3fbd31f57e39ccf8452c" + }, + "c00": "1.15.0", + "programs": [ + { + "id": 30, + "key": "case_championship", + "template": "case_championship", + "competitive": true, + "caseFieldId": 230, + "numberId": 235, + "boolId": 236, + "primary": { + "id": 45, + "name": "Кейс primary case_championship" + }, + "anchor": { + "id": 46, + "name": "Кейс anchor case_championship", + "snapshotSha256": "f75b467996c66c214aba02d3f252d74cb013a74c681a084a4f7f768a22e3d9cc" + }, + "finalStateSha256": "01092c699a691d712d8f0248be5556a1476e4ab036bacf6f52e1a12d6acad0ba", + "creationFlagOff": true + }, + { + "id": 31, + "key": "hackathon", + "template": "hackathon", + "competitive": true, + "caseFieldId": 237, + "numberId": 246, + "boolId": 247, + "primary": { + "id": 47, + "name": "Кейс primary hackathon" + }, + "anchor": { + "id": 48, + "name": "Кейс anchor hackathon", + "snapshotSha256": "2fb0eda0d0b3e1c7151f88d1b9b9d033ed0e4b7dd2f6cb5c6ba1f59b76a27dd3" + }, + "finalStateSha256": "a8a48b8520cfb8e3985bb0ec2b14f6af583ea1b388d5d2927114966dc5c53a60", + "creationFlagOff": true + }, + { + "id": 32, + "key": "noncompetitive", + "template": "case_championship", + "competitive": false, + "caseFieldId": 248, + "numberId": 253, + "boolId": 254, + "primary": { + "id": 49, + "name": "Кейс primary noncompetitive" + }, + "anchor": { + "id": 50, + "name": "Кейс anchor noncompetitive" + }, + "finalStateSha256": "13254ad87ea9a4d6778c7c21d478e948b2999a90f22e350b1c7568d49fb2b8f7", + "creationFlagOff": true + } + ], + "checks": [ + { + "program": 30, + "key": "case_championship", + "check": "read-only preview, real IDs and current case", + "solution": 45, + "current": "Город" + }, + { + "program": 30, + "key": "case_championship", + "check": "stale confirmation after actual captain PATCH preserves new name and old case" + }, + { + "program": 30, + "key": "case_championship", + "check": "one exact case answer and version change, 0/false/team/pending preserved; replay denied", + "caseName": " Образование | наука ", + "version": 5 + }, + { + "program": 30, + "key": "case_championship", + "check": "actor/target/solution binding and submitted anchor preserved", + "anchor": 46, + "competitive": true + }, + { + "program": 31, + "key": "hackathon", + "check": "read-only preview, real IDs and current case", + "solution": 47, + "current": null + }, + { + "program": 31, + "key": "hackathon", + "check": "stale confirmation after actual captain PATCH preserves new name and old case" + }, + { + "program": 31, + "key": "hackathon", + "check": "one exact case answer and version change, 0/false/team/pending preserved; replay denied", + "caseName": " Образование | наука ", + "version": 5 + }, + { + "program": 31, + "key": "hackathon", + "check": "actor/target/solution binding and submitted anchor preserved", + "anchor": 48, + "competitive": true + }, + { + "program": 32, + "key": "noncompetitive", + "check": "read-only preview, real IDs and current case", + "solution": 49, + "current": "Город" + }, + { + "program": 32, + "key": "noncompetitive", + "check": "stale confirmation after actual captain PATCH preserves new name and old case" + }, + { + "program": 32, + "key": "noncompetitive", + "check": "one exact case answer and version change, 0/false/team/pending preserved; replay denied", + "caseName": " Образование | наука ", + "version": 5 + }, + { + "program": 32, + "key": "noncompetitive", + "check": "actor/target/solution binding and submitted anchor preserved", + "anchor": 50, + "competitive": false + } + ], + "events": [ + { + "role": "manager", + "method": "GET", + "path": "/programs/30/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "ff861747cf13390e28e707882fd9574b0ceee944bec1344daf47a44dbf4e0011" + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/30/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "aa17d93a3729b5e2e4708847554168f05db6c4e41bf6462882fb03e5a63aa441" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/30/solutions/", + "status": 201, + "code": null, + "responseSha256": "374dcf8b7564faf3f241661b26cd244f0ce2483e5eed671a0d51d5cb6d1ba993" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/30/solutions/45/", + "status": 200, + "code": null, + "responseSha256": "fac5381abbae7f97e03ccff408d06f3789195a726be84c0b32ce514418c81a5d" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/30/solutions/", + "status": 201, + "code": null, + "responseSha256": "25fcea2cf0425933cd9ac44e8f6941e2260164b1e506636689cf81a35db49c87" + }, + { + "role": "pending_a", + "method": "PATCH", + "path": "/programs/30/solutions/46/", + "status": 200, + "code": null, + "responseSha256": "22e5e0b1d53d2bead0abd56fb4865eccce1f37db0a15987fee9ec837dcdb6238" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/30/solutions/46/submit/", + "status": 200, + "code": null, + "responseSha256": "f2d2e28fa655d42f5f1137fbe56fee938f0e919a449c675f0a61400f67111f91" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/30/solutions/45/", + "status": 200, + "code": null, + "responseSha256": "fac5381abbae7f97e03ccff408d06f3789195a726be84c0b32ce514418c81a5d" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/30/solutions/45/invitations/", + "status": 201, + "code": null, + "responseSha256": "462d7aac69cc123878538980d7a026aa121abbe3f5b34aaf10fde37b79ee46dc" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "responseSha256": "2a45984cbe3596d490cbb9231c2f7cc538def570d1c8606844b729156145a1e0" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "foreign_manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 401, + "code": "authentication_required", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/30/solutions/45/", + "status": 200, + "code": null, + "responseSha256": "30caacb40e09a2b12b846acbcc06470791766af7792e5ef858708f996fe39cce" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/30/solutions/45/", + "status": 200, + "code": null, + "responseSha256": "815bd0c7d4626ce1e77eaab07a4fd7a085f218576636b3fb876f8b04bebd4e99" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "responseSha256": "30a5dfb4c0beeb8683c1586e8ef86730afe12ae19769716c7d8a35a084719498" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 200, + "code": null, + "responseSha256": "3859380f8e01fab9e48005321e3df561c25ea1c6966e1aa1746a1ad252021e7b" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "0cf123c821c616a6336bd86cf87dceb50153cd5c30c53347c0ae4a1d37027ad9" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "responseSha256": "169a0a68cd7b71880d270e667002b4a6f40d3298d67371c7e657b8a2ff94e32d" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 400, + "code": "invalid_answers", + "responseSha256": "4ca693e8b3475938fec09cdf3e355a7600e3b6c5862af2bad1bd85fa606af76c" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 400, + "code": "validation_error", + "responseSha256": "e539c53a262209c04f26c57683ae38617d08ad30750aa8998b668ef3d173f7c4" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/?unknown=1", + "status": 400, + "code": "validation_error", + "responseSha256": "508eeb3418d2b08707d50692269ec5442224217b20c97251403b23e10f9f8c55" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/preview/", + "status": 200, + "code": null, + "responseSha256": "0540614192b3ec06af99dbeaacfbc41a1de1eb079b20c7149fcf1d5e52c1f968" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/45/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/46/case/preview/", + "status": 200, + "code": null, + "responseSha256": "665783298f7ec328439dc7f0292e4e304231f9c1e9836cdd1ed4ad7dfbc32cd1" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/30/management/solutions/46/case/apply/", + "status": 409, + "code": "team_frozen", + "responseSha256": "cc1daf856735cacd081f53d06e10bfcb853ebe3f4624b0efe1eb86dca54633ed" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/31/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "bb3b2c9a932ce8b4cbd9ec331664e909b180e73f948447caa2d402c18893c10b" + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/31/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "d71edeaf9b34421cdb1b49feb20cc5cc36687fb22fe79a605fcadd10c21939f7" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/31/solutions/", + "status": 201, + "code": null, + "responseSha256": "6c4c4a7579d81d7d5811e69fb6ce19227147e2389aaa8053c47126d79589bad3" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/31/solutions/47/", + "status": 200, + "code": null, + "responseSha256": "286f12e5780c1748b3e440dcc121ffd43c60825b5b6313c067473a912991e773" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/31/solutions/", + "status": 201, + "code": null, + "responseSha256": "24df4b358078adc813c5a56beaa8ebb3f224305570ba452910ff1c6f1001f03f" + }, + { + "role": "pending_a", + "method": "PATCH", + "path": "/programs/31/solutions/48/", + "status": 200, + "code": null, + "responseSha256": "001027bb68661ada2a0a3fea3482f9d613a7340cae4109f10af95a0ba148102b" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/31/solutions/48/submit/", + "status": 200, + "code": null, + "responseSha256": "8ddd7275880cbb8a95c1f1ba42ecdee223ce8e0e2ccb89bafdcf44304d5fe055" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/31/solutions/47/", + "status": 200, + "code": null, + "responseSha256": "286f12e5780c1748b3e440dcc121ffd43c60825b5b6313c067473a912991e773" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/31/solutions/47/invitations/", + "status": 201, + "code": null, + "responseSha256": "d3a94572b949097248a63893fa0cad512f7750a9ef2a54e130a6f9596e50a8a6" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "responseSha256": "69a01b82c013ef03aac7ddce60cc0bcb01457b66de2210492ebc4ea8180c7404" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "foreign_manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 401, + "code": "authentication_required", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/31/solutions/47/", + "status": 200, + "code": null, + "responseSha256": "4ebe54407ad08805ed9020087b215e32dd169ed26e3637160839226a5cd6c4d4" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/31/solutions/47/", + "status": 200, + "code": null, + "responseSha256": "cd00b72508bcc69d81f2c826b96b9cac2062a6cf9c40ec09597b6ddf68176e88" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "responseSha256": "e53f3ce06be51205f00a2fa467524bf44226fd70f35c591473e74a588d93a1e3" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 200, + "code": null, + "responseSha256": "d88499aa6299f988589f24ec9d5a320faa7ba4ca8537b237f65bf5011c009114" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "347e68b5376b09a3e3260b5a9055963c98d974ee46260de624facfd10b0cee81" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "responseSha256": "8b7c1cb66ac44b63b72edbbf11810f4a229172c8d3500d140733792a491d15f4" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 400, + "code": "invalid_answers", + "responseSha256": "003592d872fca4737d13306901e7acf04b71755442b9c3de0b4d29095800de1a" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 400, + "code": "validation_error", + "responseSha256": "e539c53a262209c04f26c57683ae38617d08ad30750aa8998b668ef3d173f7c4" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/?unknown=1", + "status": 400, + "code": "validation_error", + "responseSha256": "508eeb3418d2b08707d50692269ec5442224217b20c97251403b23e10f9f8c55" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/preview/", + "status": 200, + "code": null, + "responseSha256": "6f1915cc5d822401022f3538494c092e1c98ef6ad1366694c3c446113e943989" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/47/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/48/case/preview/", + "status": 200, + "code": null, + "responseSha256": "5fa0e80f5c39ea0a741e2949943a3adc840ee6f61dacfa38e3337d4e153fddcc" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/31/management/solutions/48/case/apply/", + "status": 409, + "code": "team_frozen", + "responseSha256": "cc1daf856735cacd081f53d06e10bfcb853ebe3f4624b0efe1eb86dca54633ed" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/32/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "dec1373969e56aff11439c5deb6671f795a233724761835d0fc3e395f70254a9" + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/32/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "9da3b1819923108481f42aa0dd4e93e2d8acce260565fe805c5700985ebfe049" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/32/solutions/", + "status": 201, + "code": null, + "responseSha256": "550135c0f7e49e0899cb720adec8c907c7162cbe2a3082712ba11a2c337a3ff5" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/32/solutions/49/", + "status": 200, + "code": null, + "responseSha256": "957149968df661daae052c33c8cba68ad6093ec7343c433f3015cacb56f321a8" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/32/solutions/", + "status": 201, + "code": null, + "responseSha256": "8b896c717e14dfb70757eaef05b7cab68e127f5e36468b03e77d65114ebfafc7" + }, + { + "role": "pending_a", + "method": "PATCH", + "path": "/programs/32/solutions/50/", + "status": 200, + "code": null, + "responseSha256": "7dd023795a91424a02da67c0bfab41d9c3c5cdef4cbe0378f207a9d8c95abebe" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/32/solutions/49/", + "status": 200, + "code": null, + "responseSha256": "957149968df661daae052c33c8cba68ad6093ec7343c433f3015cacb56f321a8" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/32/solutions/49/invitations/", + "status": 201, + "code": null, + "responseSha256": "970e61af3ea4941ae8de9e9be6580a897dc901f2a6d53ef793e53777d437eb4e" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "responseSha256": "a456ade819232b944f2fedd7a7b93633ea8db45530833967d4ae428c7c697554" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "foreign_manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb" + }, + { + "role": "anonymous", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 401, + "code": "authentication_required", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/32/solutions/49/", + "status": 200, + "code": null, + "responseSha256": "7fb2db0a0a9ae15aa9100779c5cdede0d629a4891e139cfbb3b789501d5f3def" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/32/solutions/49/", + "status": 200, + "code": null, + "responseSha256": "885864c2b5c9ddad8cc8dde8771fdaa8c8b2d5efa309058616ccef24a4ed9bb0" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "responseSha256": "6086758985e62e17752ff4554e0641f582c58ab433ce150411021a3988d81861" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 200, + "code": null, + "responseSha256": "d80b9430aa6f109bdd4c93b108ca671f7867e3051e4ad3e226c0cd83a195bc1d" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "4ac12e0c7b87616579fa9f819662ecdf6478fef1e45d141bb539dbfcdcb79801" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "responseSha256": "9cdd345df7351153541d08112fb5a944b87a7c15878d293afd6e9342972ec0df" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 400, + "code": "invalid_answers", + "responseSha256": "614cee07b3f9d2163240676d2f7d031355e27db39e552a8080bd5fe167c6b6c3" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 400, + "code": "validation_error", + "responseSha256": "e539c53a262209c04f26c57683ae38617d08ad30750aa8998b668ef3d173f7c4" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/?unknown=1", + "status": 400, + "code": "validation_error", + "responseSha256": "508eeb3418d2b08707d50692269ec5442224217b20c97251403b23e10f9f8c55" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/preview/", + "status": 200, + "code": null, + "responseSha256": "ac299238915d2638762899a3f7bae37ec85fab2fd4948e7e70e7efcc6987ddb3" + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/49/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/32/management/solutions/50/case/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "3dcabf6f26877d33ae00a05b768362cd417a5f8f2e342cfe0c2287048ef0e4e8" + } + ], + "validation": { + "responses": 82, + "requests": 70, + "rejectedInvalidRequests": 3 + }, + "baselineModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "preexistingDomainUnchanged": true, + "projectCountUnchanged": true, + "limits": [ + "Only fresh synthetic programs; external mail/CDN not used.", + "Case action is implemented for current mutable drafts; submitted administrative policy and D02 UI remain open.", + "0/false/team/pending preserved by actual snapshots; no Project was created." + ] +} diff --git a/docs/qa/program-solutions-case-actions-20261025/local-artifacts.json b/docs/qa/program-solutions-case-actions-20261025/local-artifacts.json new file mode 100644 index 00000000..711d76fc --- /dev/null +++ b/docs/qa/program-solutions-case-actions-20261025/local-artifacts.json @@ -0,0 +1,112 @@ +{ + "rawDirectory": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-case-actions", + "artifacts": [ + { + "file": "domain-and-contract-evidence.json", + "bytes": 47122, + "sha256": "1dd4ddce58257fbc2345fda28721c61dd4d2bdcfd06c6a9db6e94c1c72101860" + }, + { + "file": "http-evidence.json", + "bytes": 43126, + "sha256": "f19c64a9f4b278653ebe5485ce330cbdcd0682658e2235fea63c5c67aa2a1946" + }, + { + "file": "http-wire.json", + "bytes": 244974, + "sha256": "5f25b586c1873cd28b5a576431ce1f031884c187954f572dc38ae9e4ef46296f" + } + ], + "helpers": [ + { + "file": "procollab-case-c00.py", + "sha256": "9be8494ba6e6eeed21918e1e98d6665a01bf8b0a0b391e2cba4a08d9c2ec4233" + }, + { + "file": "procollab-case-c00-auth.py", + "sha256": "adb89fe02ef31e85878488eb92827a3dae156a45a2ff2d174d18ef067f901b79" + }, + { + "file": "procollab-case-http.py", + "sha256": "646f5707c48a0fa234875ddafaec2c285f150a98373d84b50be87f3f8b538063" + }, + { + "file": "procollab-case-verify.py", + "sha256": "282c6cd8953a92bef6713f33cff0d09c542676467bbfb52f1447ac0119fd871b" + }, + { + "file": "procollab-case-package.py", + "sha256": "bdc73a7e30d0fcf22d55502c00d58c0ea2e2cabd14850bac93e13f4e42fac21a" + } + ], + "sources": [ + { + "file": "partner_programs/services/solution_case_actions.py", + "sha256": "d037738e1aa3428403ea0a4e3b8f097551bf57cbb8839c8fe3d68633ccacb36b" + }, + { + "file": "partner_programs/solution_case_action_api.py", + "sha256": "6048400a6f2de3e6d520e8a8653995496e1719a7760c454975378f23379ed700" + }, + { + "file": "partner_programs/tests/test_solution_case_actions.py", + "sha256": "9111b16c8b49a0cde627c0db99179de8e647e41d3d89e0e94010b7b2b19a9577" + }, + { + "file": "partner_programs/urls.py", + "sha256": "f4a1e2cf2dd98056e30d0fd92a7e7b88419d7516c2ff3fbd31f57e39ccf8452c" + } + ], + "logs": [ + { + "file": "procollab-case-tests-focused.log", + "sha256": "c4f0b055b7e72cfa04a4661897f5ef2d753b155dafa641d2cbe9f6f371c6a176" + }, + { + "file": "procollab-case-tests-focused2.log", + "sha256": "58cc5282aa0aa430d3ad9a9fda5559e0dfa17bea7c08340e1bf2d544c0d0b18a" + }, + { + "file": "procollab-case-tests-focused-final.log", + "sha256": "7fdf6591de211cde982e7ef9062494afb8da5fbdbfcd234df29fa011819dceb5" + }, + { + "file": "procollab-case-backend-regression.log", + "sha256": "6edaad548796b40a3ce1a1b59097d5f6656537a9154e3d7ea52e501f35b8d6b7" + }, + { + "file": "procollab-case-final-regression.log", + "sha256": "d570924c9406cdbad0b410c41f2d05b4d99c2b32a9b59162996593c272623fcc" + }, + { + "file": "procollab-case-http.log", + "sha256": "896b54fe131c98c49fcd4aa123053e56931e9bb8deb4ef620b2b2bb3ac157c51" + }, + { + "file": "procollab-case-http-verify.log", + "sha256": "6c34d720feec09f2b23b915b4e998c9c3743c65f2dc02b00b4c97afeb9354064" + }, + { + "file": "procollab-case-fe-focused.log", + "sha256": "a24e626c84ad83360dd9bd0054776076e91725a42da64343c39facd818dbf209" + }, + { + "file": "procollab-case-fe-tests-all.log", + "sha256": "1854e4eef74c319812ca1b44681630e96686f216601edffbec9253d9043f1dec" + }, + { + "file": "procollab-case-fe-types.log", + "sha256": "9c49338a6bd87668f4525097ed9dbc34a680477f0387588bce160c2d95c749d8" + }, + { + "file": "procollab-case-fe-build.log", + "sha256": "2d1b300be8a8fbaeac9557fedb806213ff1e8706b165756ac7320e52b073ea31" + } + ], + "credentialsIncluded": false, + "notes": [ + "Main HTTP action flow:82 responses before final strict token type guard. Current service logic unchanged; API restarted and33 additional requests verified strict types/private headers/binding/data invariants.", + "Broad356 tests preceded strict token guard and final race addition. Final52 tests include19 new case tests plus existing submit and legacy actions.", + "No new visual UI, production mail/CDN, deployment, deletion or post-submitted policy change." + ] +} diff --git a/docs/qa/program-solutions-case-revision-20261025/README.md b/docs/qa/program-solutions-case-revision-20261025/README.md new file mode 100644 index 00000000..aba56102 --- /dev/null +++ b/docs/qa/program-solutions-case-revision-20261025/README.md @@ -0,0 +1,36 @@ +# BE08A: административная смена кейса после сдачи + +25.10.2026. [Контракт и модель](../../product/program-solutions-be08-case-history.md), [evidence](evidence.json), [HTTP](http-wire.json), [миграция](migration-evidence.json). Выполнен ACH01–ACH08 в описанном scope; полный BE08/QA01/REL01 остается открытым. Исключение участников, удаление регистрации и отвязка/архив заявки — следующие действия, они этим этапом не реализованы. + +Backend product91fdbc3f6199d6e0b861caa23063766afd18a385, frontend product74037240c7f10687b24b5627472e5225fb58a381. C001.17.0:131 schemas/51 operations/230 независимых сценариев; три общих файла byte-identical. Из128 прежних схем расширена только SolutionCaseChangePreview;127 прежние,3 новые.6 preview responses получили эффекты, два submitted сценария меняют прежний запрет на согласованное разрешение; всего изменены7 прежних сценариев,210 неизменны,13 новых. + +## Подтвержденное поведение + +Подписанные case preview/apply доступны организатору своей программы до и после сдачи. Сданный текущий snapshot меняет только кейс; исходный и новый снимки хранятся в SolutionAdministrativeChange. Остальные ответы, форма, состав, капитан, дата сдачи и оценки сохраняются. Снимается только несовместимое назначение без оценок; оцененные назначения сохраняются по прежнему правилу management_project_actions. + +История read-only и program/solution/change-scoped. Участник и эксперт получают403, anonymous401, чужой change404, unknown query400. Старый confirmation после другой правки и replay409; обычный captain PATCH остается409/team_frozen. Создание отключено и срок сдачи закрыт, но привилегированная смена кейса и чтение существующей работы/истории доступны. История не раскрывает регистрационные данные или email. + +56 настоящих HTTP responses /23 корректных request bodies прошли текущий C00:53 core responses КЧ/хакатона и3 дополнительных legacy boundary checks. В новых программах35/36 созданы и сданы решения51/52 с двумя принятыми участниками. Числовой0 и boolean false ответов сохранены с правильными типами. Значения оценок остаются строками «0»/«False» по действующему протоколу; default текстовый критерий с null не подменяет эти строки. + +Две новые ревизии1/2 сохраняют полные before/after snapshots и точный кейс с пробелами/pipe. В каждом решении2 оценки сохранены, из3 назначений удалено только одно неоцененное несовместимое. Настоящие expert frames до/после дают прежние критерии/оценки; снятый эксперт больше не читает эту работу. Подтверждение выполнено по одному разу на программу, история содержит по одной записи. + +Дополнительная проверка старой программы34 сохраняет legacy_project4/link3 в context. Попытки solution history/case preview в этом режиме дают409/solution_mode_unavailable и ничего не меняют. Все ранее существовавшие строки22 выбранных доменных моделей совпали с baseline. Новых Project=0;2 новых ProgramSolution,4 принятых членства,2 принятых приглашения,4 исходные оценки и новые назначения относятся только к свежим fixtures. Их setup отделен от проверяемых case actions. Auth/User.last_activity и новые fixture rows не входят в этот hash-инвариант. + +## Проверки + +- 353 native PostgreSQL solution tests PASS, включая13 новых tests:11 history/integrity и2 наблюдаемых races.32 focused case/history tests — подмножество353, не дополнительные32. Пять case race tests действительно дождались общего program lock по pg_blocking_pids; два новых проверяют submitted confirm×confirm и confirm×score update. Сбой журнала откатывает case/snapshot/version/assignment removal целиком. Изменение значения оценки при том же ID или scope делает прежнее подтверждение устаревшим. +- 148 legacy Project/invite/submit/case tests PASS на том же новом product source в отдельной Unicode test DB. Старые Project handlers и модели не менялись. +- 2862 frontend tests/481 files +strict C00 types, development build, scoped ESLint и locked Prettier3.8.3/2020 source files PASS.49 case/history wire tests включают16 новых history checks. Detail unknown-query сценарий проверен на backend/настоящем HTTP; frontend detail client намеренно не принимает произвольный query. +- Black/Flake8, Django check, migration consistency и C00 validator PASS. Предыдущий backend f9ca4c4 CI завершился success; новый product CI проверяется отдельно и этим предыдущим результатом не подменяется. + +Локальная миграция0035 создала только таблицу истории/constraints. До и после применения все прежние22 выбранные модели, включая33 программы,49 решений,4 Project, оценки и файлы, совпали по hash. Новая таблица была пуста. Схема обновлена только в local-procollab_ci и изолированной test DB; dev/production migration/deploy не выполнялись. При rollback нельзя удалять таблицу истории; creation-only flag остается способом остановить новое создание. + +## Происхождение и ограничения + +Raw: `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-case-revision`. Логи/helper hashes и source refs сохранены в evidence. JWT/credentials не записывались в wire; signed preview proofs в копии для Git заменены строкой redacted. C00 валидировался до этой redaction. Raw local wire сохраняет точные события; публичная копия отличается только удалением proofs. + +Прогон продолжался по сохраненным фазам. Первая попытка harness ожидала200 вместо правильного201 создания приглашения; существующее pending приглашение затем прочитано и принято, повторной отправки не было. Следующие ожидания уточнены по реальному score DTO: строки значений и отдельный default comment criterion. Решения/сдача/оценки не пересоздавались и не сбрасывались. Дополнительные3 legacy checks — отдельный read-only verifier, не новый полный case journey. + +Первый migration verifier остановился до DDL из-за предположения, что PK каждого выбранного model называется id; исправлен для UserFile с другим PK. Первая test-fixture версия превышала прежнюю quota, использовала неверное имя position и пыталась запрещенным обычным writer очистить template; timestamp assertion исправлен для эквивалентных Z/+00:00. Это сохраненные ошибки fixtures/harness, не скрытые продуктовые PASS. Финальные focused/full suites прошли после исправления этих ожиданий. + +Нового Angular UI, нового browser journey или полной visual/a11y/CDN приемки в этом backend/API этапе нет. D02/D03 approvals уже запрошены, вопросы повторно не задаются. Конкурсные несданные черновики, manager/results/public интеграции, остальные административные исключения и полный REL01 остаются открытыми. Старые программы не переключались и не переносились, merge/deploy нет. diff --git a/docs/qa/program-solutions-case-revision-20261025/evidence.json b/docs/qa/program-solutions-case-revision-20261025/evidence.json new file mode 100644 index 00000000..ff1dffcb --- /dev/null +++ b/docs/qa/program-solutions-case-revision-20261025/evidence.json @@ -0,0 +1,295 @@ +{ + "status": "PASS", + "scope": "BE08A case changes and history only; full BE08/QA01/REL01 open", + "backendProduct": "91fdbc3f6199d6e0b861caa23063766afd18a385", + "frontendProduct": "74037240c7f10687b24b5627472e5225fb58a381", + "c00": { + "version": "1.17.0", + "schemas": 131, + "operations": 51, + "scenarios": 230, + "sharedFiles": { + "openapi.json": "7306956b1298fe953b96f7e53ce2962f112f819067f093e282f74a90c861393c", + "examples.json": "ac3dbb1c9baa13bf42d2cfca43ad952d6d21dfa601ec61a73d49d88d7ec51815", + "README.md": "b038017284f4fb3b81cd68c460bfc5d56be20327ae203ce3d0314e6fc2dbf7d6" + }, + "changedOldSchemas": [ + "SolutionCaseChangePreview" + ], + "unchangedOldSchemas": 127, + "changedOldScenarios": [ + "case_action_preview_ready", + "case_action_preview_empty_current", + "case_action_preview_noncompetitive", + "case_action_preview_same", + "case_action_preview_submitted", + "case_action_preview_evaluation", + "case_action_apply_submitted" + ], + "unchangedOldScenarios": 210, + "addedScenarios": 13 + }, + "acceptance": { + "ACH01": "PASS in report scope", + "ACH02": "PASS in report scope", + "ACH03": "PASS in report scope", + "ACH04": "PASS in report scope", + "ACH05": "PASS in report scope", + "ACH06": "PASS in report scope", + "ACH07": "PASS in report scope", + "ACH08": "PASS in report scope" + }, + "http": { + "responses": 56, + "validatedResponses": 56, + "validRequestBodies": 23, + "core": { + "status": "PASS", + "responses": 53, + "validation": { + "responses": 53, + "validRequests": 22 + }, + "checks": [ + { + "program": 35, + "solution": 51, + "key": "case_championship", + "journal": 1, + "case": " Образование | наука ", + "originalSnapshotRetained": true, + "otherMaterialsAndTeamUnchanged": true, + "scoresZeroFalseRetained": true, + "onlyUnratedIncompatibleAssignmentRemoved": true, + "ordinaryWritersFrozen": true, + "historyPrivate": true, + "closedDeadlineAndCreationDisabled": true + }, + { + "program": 36, + "solution": 52, + "key": "hackathon", + "journal": 2, + "case": " Образование | наука ", + "originalSnapshotRetained": true, + "otherMaterialsAndTeamUnchanged": true, + "scoresZeroFalseRetained": true, + "onlyUnratedIncompatibleAssignmentRemoved": true, + "ordinaryWritersFrozen": true, + "historyPrivate": true, + "closedDeadlineAndCreationDisabled": true + } + ], + "old22ModelsUnchanged": true, + "newProjects": 0, + "newSolutions": 2, + "newRevisions": 2, + "domainRowsPreservedHash": "4b2728c3a688182627ede2f5572cc874ce0e129cfabd2d694591116e7cd8e7fe", + "scope": "Fresh local programs only; fixture setup/initial scores separate from checked signed admin changes; no UI/full release claim." + }, + "boundary": { + "status": "PASS", + "responses": 3, + "validRequests": 1, + "legacyProgram": 34, + "project": 4, + "link": 3, + "solutionPathsBlocked": true, + "legacyContextPreserved": true, + "old22ModelsUnchanged": true, + "domainWrites": 0 + }, + "redactedProofs": 8, + "redaction": "Only preview_token strings replaced after validation; no JWT/auth headers captured." + }, + "migration": { + "status": "PASS", + "database": "local-procollab_ci", + "migration": "0035_solution_administrative_history", + "oldModelsUnchanged": 22, + "hashBefore": "4b2728c3a688182627ede2f5572cc874ce0e129cfabd2d694591116e7cd8e7fe", + "hashAfter": "4b2728c3a688182627ede2f5572cc874ce0e129cfabd2d694591116e7cd8e7fe", + "rowCounts": { + "partner_programs.PartnerProgram": 33, + "partner_programs.PartnerProgramField": 249, + "partner_programs.PartnerProgramUserProfile": 98, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 49, + "partner_programs.ProgramSolutionAnswer": 237, + "partner_programs.ProgramSolutionMember": 65, + "partner_programs.SolutionInvitation": 30, + "project_rates.SolutionScore": 24, + "project_rates.SolutionExpertAssignment": 12, + "project_rates.Criteria": 52, + "users.Expert": 6, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 15, + "partner_programs.PartnerProgram_managers": 37 + }, + "newHistoryRows": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true, + "notes": "Creates new audit table/constraints; no RunPython, Project operations or rewriting existing solution snapshots." + }, + "tests": { + "nativePostgresSolution": 353, + "legacyRegression": 148, + "caseHistoryFocused": 32, + "focusedSubsetOf353": true, + "newTests": 13, + "newHistoryTests": 11, + "newObservedRaces": 2, + "totalCaseObservedRaces": 5, + "frontend": 2862, + "frontendFiles": 481, + "wire": 49, + "newHistoryWire": 16, + "strictTypes": true, + "build": true, + "scopedEslint": true, + "lockedFormat": { + "version": "3.8.3", + "filesBeforeReport": 2020, + "failures": [], + "filesWithReport": 2021 + } + }, + "programs": [ + { + "program": 35, + "solution": 51, + "kind": "case_championship", + "fields": { + "case": 257, + "materials": 258, + "zero": 259, + "boolean": 260, + "presentation": 261 + }, + "criteria": [ + 62, + 63 + ], + "journal": 1, + "completedState": { + "accepted": true, + "submitted": true, + "evaluationSeeded": true, + "applied": true, + "complete": true + }, + "initialSnapshotHash": "7a3731112023fe114bef1788336a4927ec6ad26b8e065b761e69e3c70741ccd9", + "originalScoreRowsHash": "1baf9cc36791e85d28de8d48dd9289a2e03b14393319ca385c192281089542ee", + "originalMemberRowsHash": "8ba4d42fc903a83b2b39116929e2365675a6afb624a0734fab4fe4e61784d248" + }, + { + "program": 36, + "solution": 52, + "kind": "hackathon", + "fields": { + "case": 262, + "materials": 263, + "zero": 264, + "boolean": 265, + "repository": 266 + }, + "criteria": [ + 65, + 66 + ], + "journal": 2, + "completedState": { + "accepted": true, + "submitted": true, + "evaluationSeeded": true, + "applied": true, + "complete": true + }, + "initialSnapshotHash": "1e2b9687fe9ffd7e7fef0c8dc69257f5904f16a334e3ff8d9eb1f99bfde8cc35", + "originalScoreRowsHash": "3c8bbedfeba503d59244d8d367b21034c6a34c1988c69413b3d6481915aee087", + "originalMemberRowsHash": "b842c79918c7dfda255ac1137a8711b8b5325434dfae3e4c0dbde52b036a5a21" + } + ], + "rawRoot": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-case-revision", + "rawArtifacts": { + "http-evidence.json": "2eb2c96d5d13e2f63e68d9ca1af51e61db61ee19dfbc38fbaae209f36d6a157e", + "http-wire.json": "f5cc48a2fed41ab36b5af8a20d21def67fdf7a2226cb386ee450fa46f0ddf68e", + "legacy-boundary-evidence.json": "54ecb75e12c6a7f8f396484de5a74937b9954c151e8ee81a9a82ce8ea18f1b1a", + "legacy-boundary-wire.json": "c08d75dbf7e7cbe2b753a0cfc3844e2e8a80702eee4bf6e4c10ad8d39d3c89a2", + "migration-evidence.json": "8abe0c94eb089beefe03547b9bf563d6f8b5d671c63997db80d9b86fe5526bdc", + "procollab-case-revision-build.log": "d58a2bcb8bd93117134177b2af181c1f60ddd4bb98865c84eb3fa8d8abf01cf2", + "procollab-case-revision-final-focused-tests.log": "93ea9d5bcc76dfe57ac1445788b15747799db4675068aec797a3567ba633fc9a", + "procollab-case-revision-final-types.log": "9c49338a6bd87668f4525097ed9dbc34a680477f0387588bce160c2d95c749d8", + "procollab-case-revision-focused-tests.log": "6b516b4dbe553a9cd15d67939f1f7717c88b0fd8a1d19f651fd3aaf8728dd838", + "procollab-case-revision-format.log": "e888686e2d21186a3d5938a465fd0f73272233e1540ea5e0ebb66586c1795e40", + "procollab-case-revision-frontend-suite.log": "24bf9f2db981bc91980e1c9550573c5dfd7d91c0d643334d9edb83011441c5fc", + "procollab-case-revision-history-tests.log": "774dc48a9d741ec60d6b1ff83ef3a5f2104af9b26e49a2c20812e65747197e1c", + "procollab-case-revision-http-final.log": "7b7329873c2f97697a93d538b47aa26e7a2a0e4cf94ecfad3113045fbe263664", + "procollab-case-revision-http-resumed.log": "4ea66096eb14da3ba52704a0e3f7873750cdc55f7979aadcb0302dc6367c1512", + "procollab-case-revision-http-verified.log": "da8ac3dacfb4146f48c388feda9da1582f1bdcb52996285a5893dc79f0e59dae", + "procollab-case-revision-http.log": "5e0c2665e1cecb6e9009e4c85e45ab8c400605af3236d4aa060ee2dbf702de2d", + "procollab-case-revision-initial-tests.log": "9b916bb1d91ec75132fb1235a46467577e3d0482e61df8bc711293f6abfdec8a", + "procollab-case-revision-legacy-boundary.log": "0dbc343b6147ec81533f5fa99e321252965ef8abc688022733d28c181d4ee574", + "procollab-case-revision-legacy-tests.log": "62d722e9e2cb1d0124127c7c28e776f7bb0cc70520f5e3680922b0890cc7d97b", + "procollab-case-revision-migrate-corrected.log": "aac1929115114be926c4c79a513b6d5f1366c689da37a97c5f5394b8ce6aafea", + "procollab-case-revision-migrate.log": "a20b03fd9ac91b636ebaeb45b6e0e3e8b9bcced2e1e7b16c9438e4230739672a", + "procollab-case-revision-solution-suite.log": "08502228430eeb2ecfdf84de4f9f16e66f3b0b546c09e600896adeae01b81ff9", + "procollab-case-revision-types.log": "9c49338a6bd87668f4525097ed9dbc34a680477f0387588bce160c2d95c749d8", + "procollab-case-revision-wire-tests.log": "7b795a9d1dc6c62e72d9284a54442cb2eb87df68b8ca447a8fe8d7080ecda1ed", + "procollab-case-revision-black.log": "d8d8356b9c0ae586eae32f455c85df08e3c6042a1a5b163bd3842414316952e1", + "procollab-case-revision-flake8.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "procollab-case-revision-format-final.log": "50a6ecef9e200868d4f431621dcfcb8f2ae9b89375aa6cfe4a04c9d8266cc26f" + }, + "sourceHashes": { + "partner_programs/services/solution_case_actions.py": "dde07d2fc33e8d2fa6c28eef77f0162603f2a9b835ee9d6fa84e65bd1e2d364d", + "partner_programs/solution_models.py": "f39482a5d7fa9730e8aef32cf32fbfd05d3f03e81ebb5009c933accc1bde0bd4", + "partner_programs/solution_history_api.py": "18696609fd32142fba9d4544b1bb86c3347f53cde1f1e73c2d62003345c1b01d", + "partner_programs/solution_admin.py": "94debc57ea4ec58b66cdbb8f94d686157782ae5b4fe2196d373c0b975d97fc32", + "partner_programs/solution_lifecycle_signals.py": "312e48151ed4e5352a19878ef70c4c74d708a66ac3e84c6e56d7ccdaa1ae27de", + "partner_programs/urls.py": "357d432e1c8d3e1d39b715c5d74be13327331b778bddaf9ef7750dfbb25cca0b", + "partner_programs/migrations/0035_solution_administrative_history.py": "897cf780387cd12b65f8dc6a423340351e12b24292f9a7ceb7d1530ffbb7e773", + "partner_programs/tests/test_solution_administrative_history.py": "4ab5871d96f6930cc294bcf0c37ffe74ae4e3e535dd8ec4c2d84b7d454f9d370", + "partner_programs/tests/test_solution_case_actions.py": "7011768320f8e0db9354e3397e6d9fe3cac71315169ab89020fcc7620126e3ea" + }, + "helpers": { + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-contract.py": "115958bd81542aa17c63063414d34f13d6f744780307cf344b59edf490e7d26f", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-migrate.py": "cc18452ebe1373d2e0a48521369f0f299169144d37c8e651ca2b7f171d56c068", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-migrate-before-pkfix.py": "351a827ec808a004a31a9d57073c6332c6a8fd4b59f46d76ab8421c044e15f4c", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-http.py": "aca5298c061ff4334685998322eb77beef5d19019053081af0826db9be2bed49", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-http-before-resume.py": "7db173c3871e4d4683f6ed6c882fbf91650397f0db99cec7f11ba9f93440655a", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-http-before-score-wire-fix.py": "e53319a63c464a8425ad4b7ea571c486e11da72150041a8cf21b362f4129ebbd", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-http-before-default-criterion-fix.py": "0f12e7bf568f7286b0cb273d5cd59853f26cc40a34df82bce6ed1f26f9148c21", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-legacy-boundary.py": "87675ed9e0cfda444c978c9feeeff691153948734dc53acbc7b5d980531cd7a5", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-case-revision-package.py": "64811a635965ca9af495888bd167cf1d803c952a15250cf0058306e1f3cbb160" + }, + "precedingCi": { + "head": "f9ca4c45c020b9316a9b48910d77e994a867a29d", + "postgres": 40191843680, + "general": 40191838237, + "status": "completed/success", + "notProofForNewProduct": true + }, + "limitations": [ + "No new UI/browser acceptance; D02/D03 pending", + "Only fresh local programs35/36 changed; all previously selected22 models unchanged", + "Core resumed after harness status/type/default-criterion corrections; no repeated submission or admin apply", + "No merge/deploy/real-program switch/automatic migration", + "Member exclusion/registration removal/archive still pending", + "Competitive draft publication and full QA01/REL01 open" + ], + "staticChecks": { + "black": true, + "flake8": true, + "djangoCheck": true, + "migrationConsistency": true, + "c00Validator": true, + "diffCheck": true + } +} diff --git a/docs/qa/program-solutions-case-revision-20261025/http-wire.json b/docs/qa/program-solutions-case-revision-20261025/http-wire.json new file mode 100644 index 00000000..fa846463 --- /dev/null +++ b/docs/qa/program-solutions-case-revision-20261025/http-wire.json @@ -0,0 +1,5840 @@ +[ + { + "actor": "captain", + "method": "POST", + "path": "/programs/35/solutions/", + "status": 201, + "request": { + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "response": { + "id": 51, + "program_id": 35, + "version": 1, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:02:10.806532Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 257 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 258 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 259 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 260 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "program_id": 35, + "version": 1, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:02:10.806532Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 257 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 258 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 259 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 260 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/35/solutions/51/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 54, + "version": 1, + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T13:02:11.329559Z", + "updated_at": "2026-10-25T13:02:11.329559Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "program_id": 35, + "version": 2, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:02:11.338898Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 1, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 257 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 258 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 259 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 260 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/54/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 54, + "version": 2, + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T13:02:11.329559Z", + "updated_at": "2026-10-25T13:04:42.975102Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "program_id": 35, + "version": 3, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:42.983695Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 257 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 258 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 259 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 260 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "answers": [ + { + "field_id": 257, + "value": "Город" + }, + { + "field_id": 258, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 259, + "value": 0 + }, + { + "field_id": 260, + "value": false + } + ] + }, + "response": { + "id": 51, + "program_id": 35, + "version": 4, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.391677Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 257, + "value": "Город" + }, + { + "field_id": 258, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 259, + "value": 0 + }, + { + "field_id": 260, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/35/solutions/51/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80" + }, + "response": { + "id": 51, + "program_id": 35, + "version": 5, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.717188Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "program_id": 35, + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 257, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 257, + "value": "Город" + }, + { + "field_id": 258, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 259, + "value": 0 + }, + { + "field_id": 260, + "value": false + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/35/solutions/51/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 51, + "program_id": 35, + "version": 5, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.717188Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "ca253a97baef924ce1ffd898401c1020b4548a1e2f22a24ba4126a05f9c1ffdb", + "criteria": [ + { + "id": 61, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 62, + "name": "Качество qa01ac-20261025-3acd90c4 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 63, + "name": "Проверено qa01ac-20261025-3acd90c4 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/35/solutions/51/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 51, + "program_id": 35, + "version": 5, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.717188Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "ca253a97baef924ce1ffd898401c1020b4548a1e2f22a24ba4126a05f9c1ffdb", + "criteria": [ + { + "id": 61, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 62, + "name": "Качество qa01ac-20261025-3acd90c4 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 63, + "name": "Проверено qa01ac-20261025-3acd90c4 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/35/solutions/51/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 51, + "program_id": 35, + "version": 5, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.717188Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "ca253a97baef924ce1ffd898401c1020b4548a1e2f22a24ba4126a05f9c1ffdb", + "criteria": [ + { + "id": 61, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 62, + "name": "Качество qa01ac-20261025-3acd90c4 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 63, + "name": "Проверено qa01ac-20261025-3acd90c4 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "program_id": 35, + "version": 5, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:04:43.717188Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "GET", + "path": "/programs/35/management/solutions/51/history/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/35/management/solutions/51/case/preview/", + "status": 200, + "request": { + "case_name": " Образование | наука " + }, + "response": { + "application": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "case_field_id": 257, + "current_case": "Город", + "case_name": " Образование | наука ", + "solution_version": 5, + "errors": [], + "can_apply": true, + "is_submitted": true, + "removed_assignments_count": 1, + "score_count": 2, + "preview_token": "redacted-preview-proof" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/35/management/solutions/51/case/apply/", + "status": 403, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/35/management/solutions/51/case/apply/", + "status": 200, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "solution_id": 51, + "solution_version": 6, + "updated_at": "2026-10-25T13:07:54.128014Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/35/management/solutions/51/case/apply/", + "status": 409, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [ + { + "path": "case_name", + "code": "case_unchanged", + "detail": "Выберите другой кейс.", + "field_id": 257 + } + ], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/?offset=0&limit=1", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 1, + "results": [ + { + "id": 1, + "application": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T13:07:54.144673Z", + "before_version": 5, + "after_version": 6, + "case_field_id": 257, + "previous_case": "Город", + "case_name": " Образование | наука ", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/1/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 1, + "application": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T13:07:54.144673Z", + "before_version": 5, + "after_version": 6, + "case_field_id": 257, + "previous_case": "Город", + "case_name": " Образование | наука ", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "answers": [ + { + "value": "Город", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "answers": [ + { + "value": " Образование | наука ", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T13:04:43.711724Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/1/?unexpected=1", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "unexpected", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/35/management/solutions/51/history/1001/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/35/solutions/51/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 35, + "name": "Административная смена кейса case_championship qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 51, + "program_id": 35, + "version": 6, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:07:54.128014Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": " Образование | наука ", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "ca253a97baef924ce1ffd898401c1020b4548a1e2f22a24ba4126a05f9c1ffdb", + "criteria": [ + { + "id": 61, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 62, + "name": "Качество qa01ac-20261025-3acd90c4 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 63, + "name": "Проверено qa01ac-20261025-3acd90c4 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "pending", + "method": "GET", + "path": "/programs/35/solutions/51/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/35/solutions/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "program_id": 35, + "version": 6, + "name": "Решение case_championship qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:02:10.806532Z", + "updated_at": "2026-10-25T13:07:54.128014Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:02:10.823474Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:04:42.963496Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 257, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 258, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 259, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 260, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 261, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "template": "case_championship", + "program_id": 35, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": " Образование | наука ", + "field_id": 257 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 258 + }, + { + "value": 0, + "field_id": 259 + }, + { + "value": false, + "field_id": 260 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:04:42.922208Z", + "submitted_at": "2026-10-25T13:04:43.711724Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/35/solutions/51/", + "status": 409, + "request": { + "expected_version": 6, + "expected_form_revision": "4268a49a3a7595dcecc8c5aad958c31b8b8ece61e03511a0f306d50f485bfc80", + "name": "Не изменять сданное решение" + }, + "response": { + "code": "team_frozen", + "detail": "Состав команды зафиксирован после сдачи.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/35/management/solutions/51/case/preview/", + "status": 200, + "request": { + "case_name": " Образование | наука " + }, + "response": { + "application": { + "kind": "solution", + "program_id": 35, + "solution_id": 51, + "name": "Решение case_championship qa01ac-20261025-3acd90c4" + }, + "case_field_id": 257, + "current_case": " Образование | наука ", + "case_name": " Образование | наука ", + "solution_version": 6, + "errors": [ + { + "path": "case_name", + "code": "case_unchanged", + "detail": "Выберите другой кейс.", + "field_id": 257 + } + ], + "can_apply": false, + "is_submitted": true, + "removed_assignments_count": 0, + "score_count": 2, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/36/solutions/", + "status": 201, + "request": { + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "response": { + "id": 52, + "program_id": 36, + "version": 1, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:55.897944Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 36, + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 262, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 262 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 263 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 264 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 265 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 266 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/36/solutions/52/", + "status": 200, + "request": null, + "response": { + "id": 52, + "program_id": 36, + "version": 1, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:55.897944Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 36, + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 262, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 262 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 263 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 264 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 265 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 266 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/36/solutions/52/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 55, + "version": 1, + "program": { + "id": 36, + "name": "Административная смена кейса hackathon qa01ac-20261025-3acd90c4" + }, + "solution": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T13:07:56.336243Z", + "updated_at": "2026-10-25T13:07:56.336243Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/55/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 55, + "version": 2, + "program": { + "id": 36, + "name": "Административная смена кейса hackathon qa01ac-20261025-3acd90c4" + }, + "solution": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T13:07:56.336243Z", + "updated_at": "2026-10-25T13:07:56.529651Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/36/solutions/52/", + "status": 200, + "request": null, + "response": { + "id": 52, + "program_id": 36, + "version": 3, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:56.537283Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 36, + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 262, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 262 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 263 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 264 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 265 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 266 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/36/solutions/52/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "answers": [ + { + "field_id": 262, + "value": "Город" + }, + { + "field_id": 263, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 264, + "value": 0 + }, + { + "field_id": 265, + "value": false + }, + { + "field_id": 266, + "value": "https://example.com/code/qa01ac-20261025-3acd90c4" + } + ] + }, + "response": { + "id": 52, + "program_id": 36, + "version": 4, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:56.942247Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 36, + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 262, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 262, + "value": "Город" + }, + { + "field_id": 263, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 264, + "value": 0 + }, + { + "field_id": 265, + "value": false + }, + { + "field_id": 266, + "value": "https://example.com/code/qa01ac-20261025-3acd90c4" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/36/solutions/52/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb" + }, + "response": { + "id": 52, + "program_id": 36, + "version": 5, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:57.184506Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "program_id": 36, + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 262, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука " + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "purpose": "other", + "label": "Материалы решения", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "purpose": "other", + "label": "Проверка", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 262, + "value": "Город" + }, + { + "field_id": 263, + "value": "Материалы qa01ac-20261025-3acd90c4" + }, + { + "field_id": 264, + "value": 0 + }, + { + "field_id": 265, + "value": false + }, + { + "field_id": 266, + "value": "https://example.com/code/qa01ac-20261025-3acd90c4" + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T13:02:09.980955Z", + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/36/solutions/52/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 36, + "name": "Административная смена кейса hackathon qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 52, + "program_id": 36, + "version": 5, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:57.184506Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:07:56.356577Z", + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "0aa57af49a87e2bbfa3324512e57c35eaa437797df60427b2dfd216a1162a313", + "criteria": [ + { + "id": 64, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 65, + "name": "Качество qa01ac-20261025-3acd90c4 4", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 66, + "name": "Проверено qa01ac-20261025-3acd90c4 5", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/36/solutions/52/", + "status": 200, + "request": null, + "response": { + "id": 52, + "program_id": 36, + "version": 5, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:57.184506Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:07:56.356577Z", + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "GET", + "path": "/programs/36/management/solutions/52/history/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/36/management/solutions/52/case/preview/", + "status": 200, + "request": { + "case_name": " Образование | наука " + }, + "response": { + "application": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "case_field_id": 262, + "current_case": "Город", + "case_name": " Образование | наука ", + "solution_version": 5, + "errors": [], + "can_apply": true, + "is_submitted": true, + "removed_assignments_count": 1, + "score_count": 2, + "preview_token": "redacted-preview-proof" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/36/management/solutions/52/case/apply/", + "status": 403, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/36/management/solutions/52/case/apply/", + "status": 200, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "solution_id": 52, + "solution_version": 6, + "updated_at": "2026-10-25T13:07:58.449567Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/36/management/solutions/52/case/apply/", + "status": 409, + "request": { + "case_name": " Образование | наука ", + "preview_token": "redacted-preview-proof" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [ + { + "path": "case_name", + "code": "case_unchanged", + "detail": "Выберите другой кейс.", + "field_id": 262 + } + ], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/?offset=0&limit=1", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 1, + "results": [ + { + "id": 2, + "application": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T13:07:58.459679Z", + "before_version": 5, + "after_version": 6, + "case_field_id": 262, + "previous_case": "Город", + "case_name": " Образование | наука ", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/2/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 2, + "application": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "action": "change_case", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T13:07:58.459679Z", + "before_version": 5, + "after_version": 6, + "case_field_id": 262, + "previous_case": "Город", + "case_name": " Образование | наука ", + "removed_assignments_count": 1, + "score_count": 2, + "was_submitted": true + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "answers": [ + { + "value": "Город", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "answers": [ + { + "value": " Образование | наука ", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T13:07:57.178324Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/2/?unexpected=1", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "unexpected", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/36/management/solutions/52/history/1002/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/36/solutions/52/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 36, + "name": "Административная смена кейса hackathon qa01ac-20261025-3acd90c4" + }, + "solution": { + "id": 52, + "program_id": 36, + "version": 6, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:58.449567Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": " Образование | наука ", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:07:56.356577Z", + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "0aa57af49a87e2bbfa3324512e57c35eaa437797df60427b2dfd216a1162a313", + "criteria": [ + { + "id": 64, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 65, + "name": "Качество qa01ac-20261025-3acd90c4 4", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 66, + "name": "Проверено qa01ac-20261025-3acd90c4 5", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 3, + "deadline": "2026-10-27T13:02:09.980955Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "pending", + "method": "GET", + "path": "/programs/36/solutions/52/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/36/solutions/52/", + "status": 200, + "request": null, + "response": { + "id": 52, + "program_id": 36, + "version": 6, + "name": "Решение hackathon qa01ac-20261025-3acd90c4", + "captain_id": 9, + "created_at": "2026-10-25T13:07:55.897944Z", + "updated_at": "2026-10-25T13:07:58.449567Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T13:07:55.907939Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T13:07:56.519063Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 262, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + " Образование | наука " + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 263, + "type": "textarea", + "label": "Материалы решения", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 264, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 265, + "type": "checkbox", + "label": "Проверка", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 266, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "template": "hackathon", + "program_id": 36, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": " Образование | наука ", + "field_id": 262 + }, + { + "value": "Материалы qa01ac-20261025-3acd90c4", + "field_id": 263 + }, + { + "value": 0, + "field_id": 264 + }, + { + "value": false, + "field_id": 265 + }, + { + "value": "https://example.com/code/qa01ac-20261025-3acd90c4", + "field_id": 266 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-25T13:07:56.356577Z", + "submitted_at": "2026-10-25T13:07:57.178324Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/36/solutions/52/", + "status": 409, + "request": { + "expected_version": 6, + "expected_form_revision": "a6879e02e8d11987782c87802bc49d6a8028f20aebbdf29550b995b4df431ebb", + "name": "Не изменять сданное решение" + }, + "response": { + "code": "team_frozen", + "detail": "Состав команды зафиксирован после сдачи.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/36/management/solutions/52/case/preview/", + "status": 200, + "request": { + "case_name": " Образование | наука " + }, + "response": { + "application": { + "kind": "solution", + "program_id": 36, + "solution_id": 52, + "name": "Решение hackathon qa01ac-20261025-3acd90c4" + }, + "case_field_id": 262, + "current_case": " Образование | наука ", + "case_name": " Образование | наука ", + "solution_version": 6, + "errors": [ + { + "path": "case_name", + "code": "case_unchanged", + "detail": "Выберите другой кейс.", + "field_id": 262 + } + ], + "can_apply": false, + "is_submitted": true, + "removed_assignments_count": 0, + "score_count": 2, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/34/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 34, + "storage_mode": "legacy_project", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "legacy_project", + "program_id": 34, + "project_id": 4, + "program_link_id": 3, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен" + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/34/management/solutions/51/history/", + "status": 409, + "request": null, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/34/management/solutions/51/case/preview/", + "status": 409, + "request": { + "case_name": " Образование | наука " + }, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + } +] diff --git a/docs/qa/program-solutions-case-revision-20261025/migration-evidence.json b/docs/qa/program-solutions-case-revision-20261025/migration-evidence.json new file mode 100644 index 00000000..b98c2f65 --- /dev/null +++ b/docs/qa/program-solutions-case-revision-20261025/migration-evidence.json @@ -0,0 +1,36 @@ +{ + "status": "PASS", + "database": "local-procollab_ci", + "migration": "0035_solution_administrative_history", + "oldModelsUnchanged": 22, + "hashBefore": "4b2728c3a688182627ede2f5572cc874ce0e129cfabd2d694591116e7cd8e7fe", + "hashAfter": "4b2728c3a688182627ede2f5572cc874ce0e129cfabd2d694591116e7cd8e7fe", + "rowCounts": { + "partner_programs.PartnerProgram": 33, + "partner_programs.PartnerProgramField": 249, + "partner_programs.PartnerProgramUserProfile": 98, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 49, + "partner_programs.ProgramSolutionAnswer": 237, + "partner_programs.ProgramSolutionMember": 65, + "partner_programs.SolutionInvitation": 30, + "project_rates.SolutionScore": 24, + "project_rates.SolutionExpertAssignment": 12, + "project_rates.Criteria": 52, + "users.Expert": 6, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 15, + "partner_programs.PartnerProgram_managers": 37 + }, + "newHistoryRows": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true, + "notes": "Creates new audit table/constraints; no RunPython, Project operations or rewriting existing solution snapshots." +} diff --git a/docs/qa/program-solutions-competitive-publication-20261025/README.md b/docs/qa/program-solutions-competitive-publication-20261025/README.md new file mode 100644 index 00000000..9b24355b --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/README.md @@ -0,0 +1,35 @@ + + +# CPUB: опубликованные материалы сданных КЧ/хакатон решений + +Статус: **CPUB API PASS; первый выпуск не завершен, не READY TO MERGE**. Данные живут в ProgramSolution/answers/team/UserFile; Project не создается. После окончания solution-программы и при publication flag publisher отмечает активные submitted работы. Неконкурсные saved материалы по-прежнему без искусственной сдачи. Eligibility несданных конкурсных черновиков уже запрошена и остается открытой. + +Product refs: backend `8d089229617893745c5dfa749f20d891152b9caa`, frontend `609253594c88fc8255e4841c918fc375a72e47e5`. Новых DB migrations в CPUB нет. Публичные DTO/permissions на запись не расширялись: два прежних GET возвращают allowlist по marker, конкурсные draft/архив/неопубликованные скрыты. Уже установленный marker не снимается выключением флага или переносом даты; confirmed archive скрывает выдачу, сохраняя данные. + +## Контракт и потребители + +C00 **1.21.0 / 156 schemas / 59 operations / 305 scenarios**. Все156 схем и структура59 операций неизменны; изменились описания двух public GET. Добавлены6 сценариев. **Один временный пример** `published_solution_list_competitive_pending_rule` намеренно меняет404→200 пустого списка: вопрос о draft больше не закрывает весь конкурсный reader. Остальные298 старых сценариев сохранены. Три shared файла byte-identical. Generated TS types отличаются version comment, fixtures отражают новые/обновленный примеры; существующий adapter/repository/use cases принимают тот же DTO. + +## Проверки + +- Final **432 PostgreSQL tests/OK без skips**, включая12 новых CPUB tests и3 observed lock races. Native wrapper использует `test_procollab_ci_archived_rating_unicode` (ICU ru-RU), keepdb, обычный manage.py test mode; патч изолирует только файловый logger sink Windows, не домен/БД. [Точные targets](native-targets.txt). Отдельно **4 legacy Project publisher tests/OK**; focused30 — subset, не складывать с432. +- **2955 FE tests / 484 files**, strict types, development build, scoped ESLint PASS.31 public wire tests — subset. Black/Flake8/Django check/migration consistency/C00 PASS. Build имеет прежние Sass warnings. Locked Prettier3.8.3 до новых reports:2061 files PASS; итог с reports:2068 files PASS. +- **97 немокированных HTTP ответов / 34 валидных тела**, C00 validation до редактирования Git wire. Новые программы49/50/51, решения66–71. КЧ/хакатон: create/save/invite/accept/pending/submit/rate → controlled finish/flag → scoped publisher → anonymous list/search/detail → signed case/member revision → public applied snapshot → archive → public404. Noncompetitive: saved work +пустая saved work опубликованы без submit, pagination проверена. Before finish/flagFalse publisher0, затем1/1/2, repeat0; creationFalse сохраняет чтение/админ действия. Program flags изменялись только у собственных fixtures с проверенным prefix, global main publisher не запускался. +- **25 прежних моделей** (включая Auth User, Project и C2 audit/history) совпали до/после и после final native/FE/build checks: SHA `54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169`. **Project4→4**, все7 прежних registration audit headers сохранены. У49/50 marker/архивный snapshot и4 criterion values (0/False/комментарии) сохранены, у51 нет искусственной сдачи. Creation flags трех fixturesFalse. +- Owned API wrapper10408/child39748 остановлены,8877 освобожден. Старые8769/8871/8876/55439 не менялись. Старые8871/8876 не использовались как актуальная сборка. + +[Evidence/source hashes](evidence.json), [contract compatibility](contract-compatibility.json), [HTTP wire](http-wire.json), [domain proof](domain-and-contract-evidence.json), [final readonly retention](retention-final.json). + +## Неудачные и промежуточные попытки + +До реализации3 проверки дали3 product FAIL: publisher0 вместо1 и competitive404. Первый focused30 дал2 fixture errors: вариант нового кейса не был добавлен в конфигурацию, а secret text был передан в int criterion. Fixtures исправлены на валидный кейс/str criterion, валидаторы и guards не ослаблялись. После30/OK и431/OK тесты усилены новым обязательным полем и pagination/foreign scope; final432/OK. Старые logs/failed attempts сохранены с hashes в evidence. Первый verifier исходных AC включил header в счетчик; после точного выбора все26 строк byte-exact. Fixtures/критерии/оценки ради PASS не сбрасывались, завершенный HTTP helper запрещает replay. + +JWT/credentials не входят в report. **12 signed proof occurrences скрыты только в Git-копии wire** после проверки настоящих запросов/ответов. Raw private manifest/wire и helper logs находятся в TEMP/design-artifacts; не переносить их в Git. Повторять завершенный HTTP script нельзя:66/68 уже в архиве. Допустим отдельный readonly verifier. + +## Integration finding и границы готовности + +[Read-only prospective dev graph](prospective-dev-integration.json): target BE587a9155/FE08414b79 продвинулся certificate changes. Git merge-tree preview до CPUB не имеет текстовых конфликтов, но новый dev `0031_programcertificate_rendered_batch` и feature `0038_solution_registration_removal_audit` дают **две leaf nodes**. Текущий checkout graph чист. Overlay MigrationLoader(None) не пишет в БД; migration/sync/merge/rebase/force-push не выполнялись. **MAJOR required_next**: совместить certificate models/migrations и повторить граф/retention verification без перенумерации или удаления старых миграций.432/2955 не доказывают еще не собранную объединенную версию. + +Не проверены новый Angular public UI, CDN bytes (собственные metadata fixtures), production и окончательная draft eligibility. D02/D03 уже ожидают approval; новый UI не реализован этим checkpoint. Исходные26 AC сохранены:23 PASS, AC18/19 OPEN, AC21 отдельно BE10. Полный QA01/REL01, интеграция последнего dev и весь первый выпуск остаются в scope. Старые programs автоматически не переносятся; новых merge/deploy/реальных switches нет. После первого конкурсного marker сохраняется reader/publisher1.21: старый competitive404 reader скроет опубликованные материалы при откате. + +[CPUB spec и audit](../../product/program-solutions-competitive-publication.md). diff --git a/docs/qa/program-solutions-competitive-publication-20261025/contract-compatibility.json b/docs/qa/program-solutions-competitive-publication-20261025/contract-compatibility.json new file mode 100644 index 00000000..30622d63 --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/contract-compatibility.json @@ -0,0 +1,16 @@ +{ + "status": "PASS", + "version": "1.21.0", + "schemas": 156, + "all156SchemasUnchanged": true, + "operations": 59, + "operationDescriptionsOnly": ["getPublishedSolutions", "getPublishedSolution"], + "priorOperationsOtherwiseUnchanged": 59, + "unchangedPriorScenarios": 298, + "intentionallyRevisedScenario": "published_solution_list_competitive_pending_rule", + "priorStatus": 404, + "newStatus": 200, + "addedScenarios": 6, + "totalScenarios": 305, + "sharedFilesByteIdentical": 3 +} diff --git a/docs/qa/program-solutions-competitive-publication-20261025/domain-and-contract-evidence.json b/docs/qa/program-solutions-competitive-publication-20261025/domain-and-contract-evidence.json new file mode 100644 index 00000000..4b426b8e --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/domain-and-contract-evidence.json @@ -0,0 +1,113 @@ +{ + "status": "PASS", + "contract": "1.21.0", + "responses": 97, + "validRequests": 34, + "programs": [ + { + "program": 49, + "solution": 66, + "draft": 67, + "template": "case_championship", + "competitive": true, + "marker": "2026-10-25T18:25:12.448804+00:00", + "status": "PASS" + }, + { + "program": 50, + "solution": 68, + "draft": 69, + "template": "hackathon", + "competitive": true, + "marker": "2026-10-25T18:25:15.456936+00:00", + "status": "PASS" + }, + { + "program": 51, + "solution": 70, + "draft": 71, + "template": "hackathon", + "competitive": false, + "marker": "2026-10-25T18:25:18.652630+00:00", + "status": "PASS" + } + ], + "publisherInvocations": [ + { + "program": 49, + "count": 0, + "at": "2026-10-25T18:25:12.429609+00:00" + }, + { + "program": 49, + "count": 0, + "at": "2026-10-25T18:25:12.439459+00:00" + }, + { + "program": 49, + "count": 1, + "at": "2026-10-25T18:25:12.451951+00:00" + }, + { + "program": 49, + "count": 0, + "at": "2026-10-25T18:25:12.462700+00:00" + }, + { + "program": 50, + "count": 0, + "at": "2026-10-25T18:25:15.422372+00:00" + }, + { + "program": 50, + "count": 0, + "at": "2026-10-25T18:25:15.441363+00:00" + }, + { + "program": 50, + "count": 1, + "at": "2026-10-25T18:25:15.459000+00:00" + }, + { + "program": 50, + "count": 0, + "at": "2026-10-25T18:25:15.477111+00:00" + }, + { + "program": 51, + "count": 0, + "at": "2026-10-25T18:25:18.606239+00:00" + }, + { + "program": 51, + "count": 0, + "at": "2026-10-25T18:25:18.628576+00:00" + }, + { + "program": 51, + "count": 2, + "at": "2026-10-25T18:25:18.654701+00:00" + }, + { + "program": 51, + "count": 0, + "at": "2026-10-25T18:25:18.679348+00:00" + } + ], + "oldModelsUnchanged": 25, + "hashBefore": "54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169", + "hashAfter": "54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169", + "projectCount": 4, + "noNewProject": true, + "creationFlagsFalse": true, + "closedScoresRetained": true, + "privateDataNotExposed": true, + "noncompetitiveHasNoArtificialSubmit": true, + "apiResponsesNotMocked": true, + "notChecked": [ + "new Angular public UI", + "CDN file bytes (owned metadata fixture only)", + "production", + "final competitive draft eligibility" + ] +} diff --git a/docs/qa/program-solutions-competitive-publication-20261025/evidence.json b/docs/qa/program-solutions-competitive-publication-20261025/evidence.json new file mode 100644 index 00000000..e8484dd6 --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/evidence.json @@ -0,0 +1,187 @@ +{ + "status": "CPUB_PASS_FEATURE_INCOMPLETE", + "backendProduct": "8d089229617893745c5dfa749f20d891152b9caa", + "frontendProduct": "609253594c88fc8255e4841c918fc375a72e47e5", + "contract": { + "status": "PASS", + "version": "1.21.0", + "schemas": 156, + "all156SchemasUnchanged": true, + "operations": 59, + "operationDescriptionsOnly": ["getPublishedSolutions", "getPublishedSolution"], + "priorOperationsOtherwiseUnchanged": 59, + "unchangedPriorScenarios": 298, + "intentionallyRevisedScenario": "published_solution_list_competitive_pending_rule", + "priorStatus": 404, + "newStatus": 200, + "addedScenarios": 6, + "totalScenarios": 305, + "sharedFilesByteIdentical": 3 + }, + "tests": { + "nativePostgreSQL": 432, + "newNative": 12, + "nativeSkipped": 0, + "legacyPublisher": 4, + "focusedBeforeFinalExtension": 30, + "frontend": 2955, + "frontendFiles": 484, + "publicWireSubset": 31, + "strictTypes": "PASS", + "developmentBuild": "PASS (pre-existing Sass warnings)", + "scopedESLint": "PASS", + "static": [ + { + "command": [ + "python.exe", + "-m", + "black", + "--check", + "partner_programs/services/publishing.py", + "partner_programs/services/solution_publication.py", + "partner_programs/solution_publication_api.py", + "partner_programs/tests/test_solution_publication.py", + "partner_programs/tests/test_solution_competitive_publication.py" + ], + "exitCode": 0, + "output": "All done! ✨ 🍰 ✨\n5 files would be left unchanged.\n" + }, + { + "command": [ + "python.exe", + "-m", + "flake8", + "partner_programs/services/publishing.py", + "partner_programs/services/solution_publication.py", + "partner_programs/solution_publication_api.py", + "partner_programs/tests/test_solution_publication.py", + "partner_programs/tests/test_solution_competitive_publication.py" + ], + "exitCode": 0, + "output": "" + }, + { + "command": ["python.exe", "manage.py", "check"], + "exitCode": 0, + "output": "System check identified no issues (0 silenced).\n" + }, + { + "command": ["python.exe", "manage.py", "makemigrations", "--check", "--dry-run"], + "exitCode": 0, + "output": "No changes detected\n" + }, + { + "command": ["python.exe", "docs/contracts/program-solutions-v1/validate_contract.py"], + "exitCode": 0, + "output": "PASS: OpenAPI; 156 schemas; 59 operations; 305 scenarios\n" + } + ], + "lockedPrettierBeforeReports": { + "version": "3.8.3", + "files": 2061, + "status": "PASS" + }, + "lockedPrettierWithReports": { + "version": "3.8.3", + "files": 2068, + "status": "PASS" + } + }, + "http": { + "responses": 97, + "validRequests": 34, + "APIResponsesNotMocked": true, + "publisherScopedOwnProgramsOnly": true, + "signedProofOccurrencesRedactedOnlyInGitCopy": 12 + }, + "retention": { + "status": "PASS", + "oldModelsUnchanged": 25, + "baseline": "54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169", + "afterAllNativeFrontendBuildChecks": true, + "globalRegistrationAuditsRetained": 7, + "fixtures": [ + { + "program": 49, + "solution": 66, + "scoreRows": 4, + "archived": true + }, + { + "program": 50, + "solution": 68, + "scoreRows": 4, + "archived": true + }, + { + "program": 51, + "solution": 70, + "scoreRows": 0, + "archived": false + } + ] + }, + "sourceHashes": { + "backend/partner_programs/services/publishing.py": "f81d1c1257f26081709567e554fdf523f2df0a7b3f1bdf507870ea1e7f06afc9", + "backend/partner_programs/services/solution_publication.py": "48f21b691c2d880d30bac0b18d08b15545edf9e07967ea717eeb7f2e2f4451bf", + "backend/partner_programs/solution_publication_api.py": "01e36a9dc0b37d62fb2921a26d9d59e60097c3dcfddcc909b6a99cd59c677f87", + "backend/partner_programs/tests/test_solution_publication.py": "a26e733eb5156ded66f03bf87f7cd6befa9677b4f88277803d510feb0874e085", + "backend/partner_programs/tests/test_solution_competitive_publication.py": "9f0d9c0f5d0da44660d6bb8a08cd36c4131224fe5b66f2c8402173506db31709", + "frontend/projects/social_platform/src/app/domain/solution/solution-contract.model.ts": "2f0e764f5bb7d0f871e7377f2127d127e77f049e6e021aa985cd2181a3412cd1", + "frontend/projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts": "1763b9122cd0429f64b8a6a42fc9b4a1226c09205418a26114b33dce2b5fc82f" + }, + "logs": { + "procollab-competitive-publication-red.log": "30e3e0a5311cef424d181447fba74ff95c83dfbf68f1b272fe0a1e9a8ec8d5e9", + "procollab-competitive-publication-focused.log": "7c1aaaef685c845f6568d4b242fab5eac8962fc790d53f349baf216ab85f9f76", + "procollab-competitive-publication-focused-final.log": "b8236336be3061806f79393436c07ef0f462abf87871bd89ebf5e73db7d2f592", + "procollab-competitive-publication-native.log": "b0bd91e3ee79e68a730637dc712883d8f2e84f2828441c4abb5517fd7df5bcad", + "procollab-competitive-publication-native-final.log": "de9e614d81492c33836b272e95a54cd9eba7abc4f6e349eb2d6556ee5077cddc", + "procollab-competitive-publication-legacy.log": "0bd9a87af5abafd59ab1a4a13363d81db267e334da2a224e5d093e950922e2bc", + "procollab-competitive-publication-frontend.log": "51fe37f4c40dc9ee0dcd27ec22e626baa96c283cd5ec49ee10bc0ce9ebae9366", + "procollab-competitive-publication-build.log": "5ad22324570492434867d6a897a024d2092abfb77172785d36516bac7bd41b14" + }, + "retainedAttempts": [ + "red3 failed before implementation", + "first focused30 had2 fixture errors: missing target case option and text score in int criterion; corrected fixture, no validator weakened", + "focused30 then431 passed; final432 includes added required-field and submitted-only pagination/foreign-scope assertions", + "first AC row verifier counted table header; exact26 rows verified unchanged afterward" + ], + "review": { + "CPUB01to08": "PASS in bounded API scope", + "fullFeature": "NEEDS_CHANGES", + "majorIntegrationFinding": "Observed dev certificate0031 and solution0038 form two leaves in read-only overlay; reconcile models/migrations without renumbering or deleting historical data" + }, + "qa": { + "original26RowsUnchanged": true, + "passed": "23: AC01–17/20/22–26", + "open": ["AC18", "AC19"], + "separate": "AC21/BE10" + }, + "pending": [ + "existing D02 approval", + "existing D03 approval", + "competitive unsent draft eligibility", + "new Angular organizer/public screens and full QA01/REL01", + "observed dev migration/model integration" + ], + "noNewMigrationInCPUB": true, + "noMergeDeployRealProgramSwitch": true, + "ownedHelpers": { + "stopped": true, + "wrapper": 10408, + "child": 39748, + "releasedPort": 8877, + "oldListenersUnchanged": { + "8769": 20920, + "8871": 19092, + "8876": 33332, + "55439": 8688 + } + }, + "notChecked": [ + "new Angular public UI", + "CDN bytes: owned metadata fixtures only", + "production", + "latest dev combined implementation (prospective graph conflict observed)" + ] +} diff --git a/docs/qa/program-solutions-competitive-publication-20261025/http-wire.json b/docs/qa/program-solutions-competitive-publication-20261025/http-wire.json new file mode 100644 index 00000000..ab4f752c --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/http-wire.json @@ -0,0 +1,7835 @@ +[ + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 49, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/49/solutions/", + "status": 201, + "request": { + "name": "Сданное решение case_championship-competitive" + }, + "response": { + "id": 66, + "program_id": 49, + "version": 1, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.338682Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 315 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 316 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 317 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 318 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/49/solutions/66/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 34, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 70, + "version": 1, + "program": { + "id": 49, + "name": "Публикация решений case_championship-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:11.441070Z", + "updated_at": "2026-10-25T18:25:11.441070Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/70/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 70, + "version": 2, + "program": { + "id": 49, + "name": "Публикация решений case_championship-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T18:25:11.441070Z", + "updated_at": "2026-10-25T18:25:11.528437Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/49/solutions/66/", + "status": 200, + "request": null, + "response": { + "id": 66, + "program_id": 49, + "version": 3, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.531510Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 315 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 316 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 317 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 318 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/49/solutions/66/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 35, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 71, + "version": 1, + "program": { + "id": 49, + "name": "Публикация решений case_championship-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 35, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:11.691826Z", + "updated_at": "2026-10-25T18:25:11.691826Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/49/solutions/66/", + "status": 200, + "request": null, + "response": { + "id": 66, + "program_id": 49, + "version": 4, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.695946Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 315 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 316 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 317 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 318 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/49/solutions/66/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "answers": [ + { + "field_id": 315, + "value": "Город" + }, + { + "field_id": 316, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 317, + "value": 0 + }, + { + "field_id": 318, + "value": false + }, + { + "field_id": 319, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + } + } + ] + }, + "response": { + "id": 66, + "program_id": 49, + "version": 5, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.872497Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 315, + "value": "Город" + }, + { + "field_id": 316, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 317, + "value": 0 + }, + { + "field_id": 318, + "value": false + }, + { + "field_id": 319, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + } + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/49/solutions/66/submit/", + "status": 200, + "request": { + "expected_version": 5, + "expected_form_revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734" + }, + "response": { + "id": 66, + "program_id": 49, + "version": 6, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.989790Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 315, + "value": "Город" + }, + { + "field_id": 316, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 317, + "value": 0 + }, + { + "field_id": 318, + "value": false + }, + { + "field_id": 319, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + } + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:11.987209Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "role": "other", + "method": "POST", + "path": "/programs/49/solutions/", + "status": 201, + "request": { + "name": "Черновик case_championship-competitive" + }, + "response": { + "id": 67, + "program_id": 49, + "version": 1, + "name": "Черновик case_championship-competitive", + "captain_id": 36, + "created_at": "2026-10-25T18:25:12.071824Z", + "updated_at": "2026-10-25T18:25:12.071824Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:12.075949Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 49, + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 315 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 316 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 317 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 318 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/49/solutions/66/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 49, + "name": "Публикация решений case_championship-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "id": 66, + "program_id": 49, + "version": 6, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.989790Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 33, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 315, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "program_id": 49, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 315 + }, + { + "value": "Материалы qa01cp-20261025-55fd4d0c", + "field_id": 316 + }, + { + "value": 0, + "field_id": 317 + }, + { + "value": false, + "field_id": 318 + }, + { + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + }, + "field_id": 319 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:11.987209Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "c8b2bce3ec5ff6eaa666695cf98d5afde4d02e64565cea02e990908e6153d3bd", + "criteria": [ + { + "id": 93, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 94, + "name": "Качество 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": null + }, + { + "id": 95, + "name": "Проверено 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 96, + "name": "Комментарий 4", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 3, + "required_evaluators": 1, + "deadline": "2026-10-27T18:25:10.954965Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "expert", + "method": "POST", + "path": "/programs/49/solutions/66/evaluation/", + "status": 200, + "request": { + "expected_criteria_revision": "c8b2bce3ec5ff6eaa666695cf98d5afde4d02e64565cea02e990908e6153d3bd", + "scores": [ + { + "criterion_id": 94, + "value": "0" + }, + { + "criterion_id": 95, + "value": "False" + }, + { + "criterion_id": 96, + "value": "private-score-qa01cp-20261025-55fd4d0c" + } + ] + }, + "response": { + "program": { + "id": 49, + "name": "Публикация решений case_championship-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "id": 66, + "program_id": 49, + "version": 6, + "name": "Сданное решение case_championship-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:11.338682Z", + "updated_at": "2026-10-25T18:25:11.989790Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 33, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:11.344320Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 315, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "ef1e0b0dff5b0f1546a6a2c523cbac372599c4d5608154102514674bfb902734", + "template": "case_championship", + "program_id": 49, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 315 + }, + { + "value": "Материалы qa01cp-20261025-55fd4d0c", + "field_id": 316 + }, + { + "value": 0, + "field_id": 317 + }, + { + "value": false, + "field_id": 318 + }, + { + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + }, + "field_id": 319 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:11.987209Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "c8b2bce3ec5ff6eaa666695cf98d5afde4d02e64565cea02e990908e6153d3bd", + "criteria": [ + { + "id": 93, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 94, + "name": "Качество 2", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 95, + "name": "Проверено 3", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 96, + "name": "Комментарий 4", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "private-score-qa01cp-20261025-55fd4d0c" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 1, + "deadline": "2026-10-27T18:25:10.954965Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/?limit=1", + "status": 200, + "request": null, + "response": { + "program_id": 49, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "published_at": "2026-10-25T18:25:12.448804Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/66/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "published_at": "2026-10-25T18:25:12.448804Z", + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 315, + "value": "Город" + }, + { + "field_id": 316, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 317, + "value": 0 + }, + { + "field_id": 318, + "value": false + }, + { + "field_id": 319, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + } + } + ], + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "is_captain": false + } + ], + "accepted_count": 2 + } + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/?search=%D0%A1%D0%B4%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5+%D1%80%D0%B5%D1%88%D0%B5%D0%BD%D0%B8%D0%B5+case_championship-competitive", + "status": 200, + "request": null, + "response": { + "program_id": 49, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "published_at": "2026-10-25T18:25:12.448804Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/67/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/49/published-solutions/67/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/49/published-solutions/67/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/49/published-solutions/67/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "other", + "method": "GET", + "path": "/programs/49/published-solutions/67/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/solutions/66/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "pending", + "method": "GET", + "path": "/programs/49/solutions/66/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/?submission=all", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "submission", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/66/?unknown=1", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "unknown", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/9007199254740992/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "{'detail': ErrorDetail(string='Given token not valid for any token type', code='token_not_valid'), 'code': ErrorDetail(string='token_not_valid', code='token_not_valid'), 'messages': [{'token_class': ErrorDetail(string='AccessToken', code='token_not_valid'), 'token_type': ErrorDetail(string='access', code='token_not_valid'), 'message': ErrorDetail(string='Token is invalid or expired', code='token_not_valid')}]}", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 49, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "published_at": "2026-10-25T18:25:12.448804Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/66/case/preview/", + "status": 200, + "request": { + "case_name": "Образование" + }, + "response": { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "case_field_id": 315, + "current_case": "Город", + "case_name": "Образование", + "solution_version": 6, + "errors": [], + "can_apply": true, + "is_submitted": true, + "removed_assignments_count": 0, + "score_count": 4, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/66/case/apply/", + "status": 200, + "request": { + "case_name": "Образование", + "preview_token": "" + }, + "response": { + "solution_id": 66, + "solution_version": 7, + "updated_at": "2026-10-25T18:25:13.620522Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/66/members/remove/preview/", + "status": 200, + "request": { + "user_id": 34 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "member": { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:11.523754Z", + "is_captain": false, + "specialization": null + }, + "solution_version": 7, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 4, + "errors": [], + "can_apply": true, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/66/members/remove/apply/", + "status": 200, + "request": { + "user_id": 34, + "preview_token": "" + }, + "response": { + "solution_id": 66, + "solution_version": 8, + "updated_at": "2026-10-25T18:25:13.777803Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/66/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "published_at": "2026-10-25T18:25:12.448804Z", + "fields": [ + { + "id": 315, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 316, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 317, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 318, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 319, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 315, + "value": "Образование" + }, + { + "field_id": 316, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 317, + "value": 0 + }, + { + "field_id": 318, + "value": false + }, + { + "field_id": 319, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-49.pdf" + } + } + ], + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [66] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "solution_version": 8, + "is_submitted": true, + "is_archived": false, + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "errors": [] + } + ], + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/49/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [66], + "preview_token": "" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 49, + "solution_id": 66, + "name": "Сданное решение case_championship-competitive" + }, + "solution_version": 9, + "archived_at": "2026-10-25T18:25:14.031890Z" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 49, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/49/published-solutions/66/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 50, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/50/solutions/", + "status": 201, + "request": { + "name": "Сданное решение hackathon-competitive" + }, + "response": { + "id": 68, + "program_id": 50, + "version": 1, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:14.307696Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 320 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 321 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 322 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 323 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 325 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/50/solutions/68/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 34, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 72, + "version": 1, + "program": { + "id": 50, + "name": "Публикация решений hackathon-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:14.423092Z", + "updated_at": "2026-10-25T18:25:14.423092Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/72/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 72, + "version": 2, + "program": { + "id": 50, + "name": "Публикация решений hackathon-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T18:25:14.423092Z", + "updated_at": "2026-10-25T18:25:14.514171Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/50/solutions/68/", + "status": 200, + "request": null, + "response": { + "id": 68, + "program_id": 50, + "version": 3, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:14.517198Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 320 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 321 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 322 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 323 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 325 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/50/solutions/68/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 35, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 73, + "version": 1, + "program": { + "id": 50, + "name": "Публикация решений hackathon-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 35, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:14.683499Z", + "updated_at": "2026-10-25T18:25:14.683499Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/50/solutions/68/", + "status": 200, + "request": null, + "response": { + "id": 68, + "program_id": 50, + "version": 4, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:14.687149Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 320 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 321 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 322 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 323 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 325 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/50/solutions/68/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "answers": [ + { + "field_id": 320, + "value": "Город" + }, + { + "field_id": 321, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 322, + "value": 0 + }, + { + "field_id": 323, + "value": false + }, + { + "field_id": 324, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + } + }, + { + "field_id": 325, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ] + }, + "response": { + "id": 68, + "program_id": 50, + "version": 5, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:14.880164Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 320, + "value": "Город" + }, + { + "field_id": 321, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 322, + "value": 0 + }, + { + "field_id": 323, + "value": false + }, + { + "field_id": 324, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + } + }, + { + "field_id": 325, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/50/solutions/68/submit/", + "status": 200, + "request": { + "expected_version": 5, + "expected_form_revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808" + }, + "response": { + "id": 68, + "program_id": 50, + "version": 6, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:15.020120Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 320, + "value": "Город" + }, + { + "field_id": 321, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 322, + "value": 0 + }, + { + "field_id": 323, + "value": false + }, + { + "field_id": 324, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + } + }, + { + "field_id": 325, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:15.016593Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "role": "other", + "method": "POST", + "path": "/programs/50/solutions/", + "status": 201, + "request": { + "name": "Черновик hackathon-competitive" + }, + "response": { + "id": 69, + "program_id": 50, + "version": 1, + "name": "Черновик hackathon-competitive", + "captain_id": 36, + "created_at": "2026-10-25T18:25:15.133099Z", + "updated_at": "2026-10-25T18:25:15.133099Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:15.137137Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 50, + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 320 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 321 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 322 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 323 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 325 + } + ] + } + }, + "cacheControl": null + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/50/solutions/68/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 50, + "name": "Публикация решений hackathon-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "id": 68, + "program_id": 50, + "version": 6, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:15.020120Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 33, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 320, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 5, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "program_id": 50, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 320 + }, + { + "value": "Материалы qa01cp-20261025-55fd4d0c", + "field_id": 321 + }, + { + "value": 0, + "field_id": 322 + }, + { + "value": false, + "field_id": 323 + }, + { + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + }, + "field_id": 324 + }, + { + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c", + "field_id": 325 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:15.016593Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "b07e0c39115e6bd9dd87a97292e0d652f7ddd055b3b80738099a21ec61b3543e", + "criteria": [ + { + "id": 97, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 98, + "name": "Качество 5", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": null + }, + { + "id": 99, + "name": "Проверено 6", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 100, + "name": "Комментарий 7", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 3, + "required_evaluators": 1, + "deadline": "2026-10-27T18:25:10.954965Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "expert", + "method": "POST", + "path": "/programs/50/solutions/68/evaluation/", + "status": 200, + "request": { + "expected_criteria_revision": "b07e0c39115e6bd9dd87a97292e0d652f7ddd055b3b80738099a21ec61b3543e", + "scores": [ + { + "criterion_id": 98, + "value": "0" + }, + { + "criterion_id": 99, + "value": "False" + }, + { + "criterion_id": 100, + "value": "private-score-qa01cp-20261025-55fd4d0c" + } + ] + }, + "response": { + "program": { + "id": 50, + "name": "Публикация решений hackathon-competitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "id": 68, + "program_id": 50, + "version": 6, + "name": "Сданное решение hackathon-competitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:14.307696Z", + "updated_at": "2026-10-25T18:25:15.020120Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 33, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:14.311111Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 320, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "label": "Презентация", + "options": [], + "purpose": "presentation", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 5, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "1b67ffc516194714476107fc88cf254a4aeac359a5699fd32f399e03e8b13808", + "template": "hackathon", + "program_id": 50, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 320 + }, + { + "value": "Материалы qa01cp-20261025-55fd4d0c", + "field_id": 321 + }, + { + "value": 0, + "field_id": 322 + }, + { + "value": false, + "field_id": 323 + }, + { + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + }, + "field_id": 324 + }, + { + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c", + "field_id": 325 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": "2026-10-25T18:25:15.016593Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "b07e0c39115e6bd9dd87a97292e0d652f7ddd055b3b80738099a21ec61b3543e", + "criteria": [ + { + "id": 97, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": "" + }, + { + "id": 98, + "name": "Качество 5", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 99, + "name": "Проверено 6", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 100, + "name": "Комментарий 7", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": "private-score-qa01cp-20261025-55fd4d0c" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": true, + "rated_count": 1, + "max_rates": 3, + "required_evaluators": 1, + "deadline": "2026-10-27T18:25:10.954965Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/?limit=1", + "status": 200, + "request": null, + "response": { + "program_id": 50, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "published_at": "2026-10-25T18:25:15.456936Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/68/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "published_at": "2026-10-25T18:25:15.456936Z", + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 320, + "value": "Город" + }, + { + "field_id": 321, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 322, + "value": 0 + }, + { + "field_id": 323, + "value": false + }, + { + "field_id": 324, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + } + }, + { + "field_id": 325, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "is_captain": false + } + ], + "accepted_count": 2 + } + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/?search=%D0%A1%D0%B4%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5+%D1%80%D0%B5%D1%88%D0%B5%D0%BD%D0%B8%D0%B5+hackathon-competitive", + "status": 200, + "request": null, + "response": { + "program_id": 50, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "published_at": "2026-10-25T18:25:15.456936Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/69/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/50/published-solutions/69/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/50/published-solutions/69/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/50/published-solutions/69/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "other", + "method": "GET", + "path": "/programs/50/published-solutions/69/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/solutions/68/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "pending", + "method": "GET", + "path": "/programs/50/solutions/68/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/?submission=all", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "submission", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/68/?unknown=1", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "unknown", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/9007199254740992/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "{'detail': ErrorDetail(string='Given token not valid for any token type', code='token_not_valid'), 'code': ErrorDetail(string='token_not_valid', code='token_not_valid'), 'messages': [{'token_class': ErrorDetail(string='AccessToken', code='token_not_valid'), 'token_type': ErrorDetail(string='access', code='token_not_valid'), 'message': ErrorDetail(string='Token is invalid or expired', code='token_not_valid')}]}", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 50, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "published_at": "2026-10-25T18:25:15.456936Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/68/case/preview/", + "status": 200, + "request": { + "case_name": "Образование" + }, + "response": { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "case_field_id": 320, + "current_case": "Город", + "case_name": "Образование", + "solution_version": 6, + "errors": [], + "can_apply": true, + "is_submitted": true, + "removed_assignments_count": 0, + "score_count": 4, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/68/case/apply/", + "status": 200, + "request": { + "case_name": "Образование", + "preview_token": "" + }, + "response": { + "solution_id": 68, + "solution_version": 7, + "updated_at": "2026-10-25T18:25:16.739417Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/68/members/remove/preview/", + "status": 200, + "request": { + "user_id": 34 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "member": { + "role": "Аналитик", + "user": { + "id": 34, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T18:25:14.508117Z", + "is_captain": false, + "specialization": null + }, + "solution_version": 7, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 4, + "errors": [], + "can_apply": true, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/68/members/remove/apply/", + "status": 200, + "request": { + "user_id": 34, + "preview_token": "" + }, + "response": { + "solution_id": 68, + "solution_version": 8, + "updated_at": "2026-10-25T18:25:17.023912Z" + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/68/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "published_at": "2026-10-25T18:25:15.456936Z", + "fields": [ + { + "id": 320, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 321, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 322, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 323, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 324, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 325, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 320, + "value": "Образование" + }, + { + "field_id": 321, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 322, + "value": 0 + }, + { + "field_id": 323, + "value": false + }, + { + "field_id": 324, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-50.pdf" + } + }, + { + "field_id": 325, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/archive/preview/", + "status": 200, + "request": { + "ids": [68] + }, + "response": { + "items": [ + { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "solution_version": 8, + "is_submitted": true, + "is_archived": false, + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "errors": [] + } + ], + "score_count": 4, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "can_apply": true, + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/50/management/solutions/archive/apply/", + "status": 200, + "request": { + "ids": [68], + "preview_token": "" + }, + "response": { + "changed_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 50, + "solution_id": 68, + "name": "Сданное решение hackathon-competitive" + }, + "solution_version": 9, + "archived_at": "2026-10-25T18:25:17.291952Z" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 50, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/50/published-solutions/68/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 51, + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/51/solutions/", + "status": 201, + "request": { + "name": "Сданное решение hackathon-noncompetitive" + }, + "response": { + "id": 70, + "program_id": 51, + "version": 1, + "name": "Сданное решение hackathon-noncompetitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:17.719046Z", + "updated_at": "2026-10-25T18:25:17.719046Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:17.723325Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 51, + "revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/51/solutions/70/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 34, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 74, + "version": 1, + "program": { + "id": 51, + "name": "Публикация решений hackathon-noncompetitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:17.838143Z", + "updated_at": "2026-10-25T18:25:17.838143Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/74/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 74, + "version": 2, + "program": { + "id": 51, + "name": "Публикация решений hackathon-noncompetitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T18:25:17.838143Z", + "updated_at": "2026-10-25T18:25:17.930729Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/51/solutions/70/", + "status": 200, + "request": null, + "response": { + "id": 70, + "program_id": 51, + "version": 3, + "name": "Сданное решение hackathon-noncompetitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:17.719046Z", + "updated_at": "2026-10-25T18:25:17.934320Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:17.723325Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:17.926001Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 51, + "revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/51/solutions/70/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 35, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 75, + "version": 1, + "program": { + "id": 51, + "name": "Публикация решений hackathon-noncompetitive qa01cp-20261025-55fd4d0c" + }, + "solution": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 35, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T18:25:18.200861Z", + "updated_at": "2026-10-25T18:25:18.201866Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/51/solutions/70/", + "status": 200, + "request": null, + "response": { + "id": 70, + "program_id": 51, + "version": 4, + "name": "Сданное решение hackathon-noncompetitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:17.719046Z", + "updated_at": "2026-10-25T18:25:18.204930Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:17.723325Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:17.926001Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 51, + "revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/51/solutions/70/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "answers": [ + { + "field_id": 326, + "value": "Город" + }, + { + "field_id": 327, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 328, + "value": 0 + }, + { + "field_id": 329, + "value": false + }, + { + "field_id": 330, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-51.pdf" + } + }, + { + "field_id": 331, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ] + }, + "response": { + "id": 70, + "program_id": 51, + "version": 5, + "name": "Сданное решение hackathon-noncompetitive", + "captain_id": 33, + "created_at": "2026-10-25T18:25:17.719046Z", + "updated_at": "2026-10-25T18:25:18.428362Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:17.723325Z", + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T18:25:17.926001Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 51, + "revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 326, + "value": "Город" + }, + { + "field_id": 327, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 328, + "value": 0 + }, + { + "field_id": 329, + "value": false + }, + { + "field_id": 330, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-51.pdf" + } + }, + { + "field_id": 331, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "role": "other", + "method": "POST", + "path": "/programs/51/solutions/", + "status": 201, + "request": { + "name": "Черновик hackathon-noncompetitive" + }, + "response": { + "id": 71, + "program_id": 51, + "version": 1, + "name": "Черновик hackathon-noncompetitive", + "captain_id": 36, + "created_at": "2026-10-25T18:25:18.542531Z", + "updated_at": "2026-10-25T18:25:18.542531Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T18:25:18.545996Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 51, + "revision": "9739c26eb756e7196e9c489de23e5ee86d2cb1f160421d257838f1ae1a9a0eba", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T18:25:10.954965Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/?limit=1", + "status": 200, + "request": null, + "response": { + "program_id": 51, + "count": 2, + "next": "http://127.0.0.1:8877/programs/51/published-solutions/?limit=1&offset=1", + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/70/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [ + { + "field_id": 326, + "value": "Город" + }, + { + "field_id": 327, + "value": "Материалы qa01cp-20261025-55fd4d0c" + }, + { + "field_id": 328, + "value": 0 + }, + { + "field_id": 329, + "value": false + }, + { + "field_id": 330, + "value": { + "url": "https://cdn.test/uploads/qa01cp-20261025-55fd4d0c-51.pdf" + } + }, + { + "field_id": 331, + "value": "https://example.org/repository/qa01cp-20261025-55fd4d0c" + } + ], + "team": { + "members": [ + { + "user": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + }, + { + "user": { + "id": 34, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "is_captain": false + } + ], + "accepted_count": 2 + } + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/?search=%D0%A1%D0%B4%D0%B0%D0%BD%D0%BD%D0%BE%D0%B5+%D1%80%D0%B5%D1%88%D0%B5%D0%BD%D0%B8%D0%B5+hackathon-noncompetitive", + "status": 200, + "request": null, + "response": { + "program_id": 51, + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/?limit=1&offset=1", + "status": 200, + "request": null, + "response": { + "program_id": 51, + "count": 2, + "next": null, + "previous": "http://127.0.0.1:8877/programs/51/published-solutions/?limit=1&offset=0", + "results": [ + { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "captain": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/71/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/51/published-solutions/71/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/51/published-solutions/71/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "expert", + "method": "GET", + "path": "/programs/51/published-solutions/71/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "other", + "method": "GET", + "path": "/programs/51/published-solutions/71/", + "status": 200, + "request": null, + "response": { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "fields": [ + { + "id": 326, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 327, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 328, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 329, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 330, + "type": "file", + "purpose": "presentation", + "label": "Презентация", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 331, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "answers": [], + "team": { + "members": [ + { + "user": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "is_captain": true + } + ], + "accepted_count": 1 + } + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/solutions/70/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "pending", + "method": "GET", + "path": "/programs/51/solutions/70/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/?submission=all", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "submission", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/70/?unknown=1", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "unknown", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/9007199254740992/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/", + "status": 401, + "request": null, + "response": { + "code": "authentication_required", + "detail": "{'detail': ErrorDetail(string='Given token not valid for any token type', code='token_not_valid'), 'code': ErrorDetail(string='token_not_valid', code='token_not_valid'), 'messages': [{'token_class': ErrorDetail(string='AccessToken', code='token_not_valid'), 'token_type': ErrorDetail(string='access', code='token_not_valid'), 'message': ErrorDetail(string='Token is invalid or expired', code='token_not_valid')}]}", + "errors": [], + "meta": {} + }, + "cacheControl": "no-store" + }, + { + "role": "anonymous", + "method": "GET", + "path": "/programs/51/published-solutions/", + "status": 200, + "request": null, + "response": { + "program_id": 51, + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 70, + "name": "Сданное решение hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "captain": { + "id": 33, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 2 + }, + { + "application": { + "kind": "solution", + "program_id": 51, + "solution_id": 71, + "name": "Черновик hackathon-noncompetitive" + }, + "published_at": "2026-10-25T18:25:18.652630Z", + "captain": { + "id": 36, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "accepted_count": 1 + } + ] + }, + "cacheControl": "no-store" + } +] diff --git a/docs/qa/program-solutions-competitive-publication-20261025/native-targets.txt b/docs/qa/program-solutions-competitive-publication-20261025/native-targets.txt new file mode 100644 index 00000000..36a80ad0 --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/native-targets.txt @@ -0,0 +1,31 @@ +partner_programs.tests.test_solution_access_boundaries +partner_programs.tests.test_solution_administrative_history +partner_programs.tests.test_solution_answers +partner_programs.tests.test_solution_archive_actions +partner_programs.tests.test_solution_case_actions +partner_programs.tests.test_solution_case_analytics +partner_programs.tests.test_solution_competitive_publication +partner_programs.tests.test_solution_creation_switch +partner_programs.tests.test_solution_entrypoints +partner_programs.tests.test_solution_expert_reminders +partner_programs.tests.test_solution_field_filters +partner_programs.tests.test_solution_forms +partner_programs.tests.test_solution_foundation +partner_programs.tests.test_solution_invitation_roles +partner_programs.tests.test_solution_invitations +partner_programs.tests.test_solution_manager_analytics +partner_programs.tests.test_solution_member_actions +partner_programs.tests.test_solution_participant_reminders +partner_programs.tests.test_solution_program_routing +partner_programs.tests.test_solution_publication +partner_programs.tests.test_solution_registration_actions +partner_programs.tests.test_solution_role_widget +partner_programs.tests.test_solution_submission +partner_programs.tests.test_solution_submission_actions +partner_programs.tests.test_solution_team +project_rates.tests.test_solution_admin +project_rates.tests.test_solution_allocation +project_rates.tests.test_solution_cabinet +project_rates.tests.test_solution_evaluation +project_rates.tests.test_solution_results +project_rates.tests.test_solution_results_export diff --git a/docs/qa/program-solutions-competitive-publication-20261025/prospective-dev-integration.json b/docs/qa/program-solutions-competitive-publication-20261025/prospective-dev-integration.json new file mode 100644 index 00000000..9659e047 --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/prospective-dev-integration.json @@ -0,0 +1,22 @@ +{ + "status": "PROSPECTIVE_INTEGRATION_CONFLICT", + "currentCheckoutGraphConflicts": {}, + "overlayWithObservedDevMigration": { + "partner_programs": [ + "0031_programcertificate_rendered_batch", + "0038_solution_registration_removal_audit" + ] + }, + "readOnlyPreview": true, + "noMigrationsApplied": true, + "upstreamMigrationSHA256": "2fe9364b189b5f7db866d67a77eb3d171a131f250215a621ac7bb280ec005e66", + "backendDev": "587a9155b9f53d9e50981a9939a7007c19fd6ecc", + "frontendDev": "08414b794dd5b34de5471dddaa19cd0af7f4a167", + "backendSourceBase": "c68a004e858fd181dd513d01641dc841746178fe", + "frontendSourceBase": "1590518422749a19f375c4de0d781748beaf2f3a", + "gitMergeTreePreview": { + "backend": "dc7cd12561372bdac6e42f7e7f7426932568ed5a", + "frontend": "39b8c405f1408eb7e160aa03f2069dd6f10be49c" + }, + "requiredNext": "Reconcile observed dev certificate migration and model compatibility; preserve all existing migration names/data; no branch merge/rebase/force-push performed" +} diff --git a/docs/qa/program-solutions-competitive-publication-20261025/retention-final.json b/docs/qa/program-solutions-competitive-publication-20261025/retention-final.json new file mode 100644 index 00000000..99936047 --- /dev/null +++ b/docs/qa/program-solutions-competitive-publication-20261025/retention-final.json @@ -0,0 +1,27 @@ +{ + "status": "PASS", + "oldModelsUnchanged": 25, + "baseline": "54631ba6896d1d18cadf7f855ca1fd1b83b0a2396a72b28e2b61b720f1783169", + "afterAllNativeFrontendBuildChecks": true, + "globalRegistrationAuditsRetained": 7, + "fixtures": [ + { + "program": 49, + "solution": 66, + "scoreRows": 4, + "archived": true + }, + { + "program": 50, + "solution": 68, + "scoreRows": 4, + "archived": true + }, + { + "program": 51, + "solution": 70, + "scoreRows": 0, + "archived": false + } + ] +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/README.md b/docs/qa/program-solutions-creation-switch-20261025/README.md new file mode 100644 index 00000000..e3ab734b --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/README.md @@ -0,0 +1,29 @@ +# REL01: создание отдельно от доступа к существующим решениям + +Технический scope ROL01–ROL10 PASS; **полный QA01/REL01 и новая сущность еще не завершены**. [Spec, before/after, rollout и audit](../../product/program-solutions-rel01.md). + +## Источник и среда + +Backend feature поверх 40ba5a7; frontend поверх e57fb04e, C00 1.13.0. Local PostgreSQL 18/main procollab_ci, API 8871, SPA 8875, locmem mail/local file storage. Тесты используют отдельный test_procollab_ci_unicode. Реальные программы/production не включались, merge/deploy не выполнялись. + +Миграция добавила False всем прежним программам без смены mode или данных. Rehearsal создал только новые synthetic программы 9/10, переиспользовал пять прежних synthetic аккаунтов. Submitted решения 8/10 и re-enabled drafts 9/11 сохранены; final creation flags False. Пароли/JWT/fixture credential manifest в Git не включены. + +## Evidence + +- [contract-compatibility.json](contract-compatibility.json): 120 схем/45 операций/161 независимый сценарий. Все прежние 158 сценариев, схемы, request/response schemas, параметры и прежние примеры совпадают с базой; три общих C00 файла byte-identical в двух repos. Type model отличается только header версии. +- [migration-evidence.json](migration-evidence.json): actual 0033→0034, 8 прежних программ и все перечисленные доменные строки/columns сохранены, flags=False. Это не перенос BE10. +- [browser-evidence.json](browser-evidence.json): off на direct new route и program entry → реальный HTTP deny → scoped CLI enable → create → disable → нормальный participant journey → submit/frozen → re-enable/new draft → disable. Обе формы, Mont, JS errors=[], 37 mutating HTTP requests, включая ожидаемые 409. Это не число persisted writes и не zero writes. +- [domain-evidence.json](domain-evidence.json): 30 HTTP checks с C00 validation, currentApplication/repeat/read/permissions/forbidden/disabled/повторная сдача и actual database snapshots; 0 domain mutations этого verifier. PDF download bytes/MIME/owner/597 bytes/SHA-256 подтверждены. Исходная выборка migration baseline восстановлена исключением только новых rehearsal строк/файлов, ее полный SHA-256 совпал: прежние программы/решения/ответы/команда/приглашения/оценки/назначения/files/Project не изменились. +- [run-state.json](run-state.json): проверенные фазы/IDs и rollbackVerified, без сброса submitted fixtures. +- [build-provenance.json](build-provenance.json): только origins одного generated chunk изменены в копии готового development build; хеши исходных frontend files и JS до/после. Source environments/обычный build не патчились. +- [local-artifacts.json](local-artifacts.json): хеши 16 screenshots, шести harness sources в Temp и final backend sources. Browser предшествовал последнему model.save guard fix; после него прошли 261 regression tests и 30 HTTP checks на перезапущенном API. UI/creation gate не менялись после browser прохода. + +## Проверки и review + +`partner_programs/tests/test_solution_creation_switch.py`: 17 новых tests. Проверяется сохранение реальных доменных значений/права, а не только наличие guard. Два PostgreSQL concurrency tests дожидаются фактического pg_blocking_pids для program lock. Migration test сохраняет submitted snapshot/answers/team и старые columns, восстанавливает latest migration graph. CLI preview/apply/idempotency/unknown/unsafe/legacy enable и stale save в обоих направлениях подтверждены. Existing writers, expert rate/manager results и noncompetitive public publisher проверены при False. + +Final backend regression: 261 tests, включая новые 17, все test_solution* + legacy services + solution evaluation + chat permissions/Swagger. Лог C:/Users/evsee/AppData/Local/Temp/procollab-solution-creation-regression.log. Black/Flake8/check/migration consistency/C00 PASS. Frontend: 55 focused (6 новых), полный 2760/478 + strict types, development build, scoped ESLint и locked Prettier 3.8.3/1969 files PASS. Логи procollab-solution-creation-fe-* в Temp. Полный Linux CI этого runtime запускается после push и не подменяется предшествующим зеленым 1724bca. + +Review исправил единственный найденный риск: generic save устаревшей карточки мог отменить операторский disable/enable. Теперь существующий program lock перечитывает и сохраняет служебный flag, если update_fields явно его не выбирает; регрессионный test и общий прогон выполнены после fix. Остальные продуктовые права/сроки/snapshot/контракты не менялись. + +Визуально просмотрены paused direct route КЧ и existing program-entry modal хакатона. Основной browser путь 390 px; состояния team/materials/readonly проверены на 320/390/768/1440, desktop материалы отдельно. Полные desktop/a11y/zoom, production CDN/рассылки не заявляются. Внешняя сеть блокировалась, default avatars отсутствуют в screenshots. D02/D03 и спорные административные/конкурсные правила этим checkpoint не согласуются и не реализуются. diff --git a/docs/qa/program-solutions-creation-switch-20261025/browser-evidence.json b/docs/qa/program-solutions-creation-switch-20261025/browser-evidence.json new file mode 100644 index 00000000..5fcc1f54 --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/browser-evidence.json @@ -0,0 +1,1075 @@ +{ + "status": "PASS", + "checkedAt": "2026-10-25T04:46:05.228Z", + "prefix": "rel01-20261025-f7bd7145", + "checks": [ + { + "program": 9, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 9, + "storage_mode": "solution", + "previous_enabled": false, + "requested_enabled": true, + "applied": true, + "changed": true + } + }, + { + "program": 9, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 9, + "storage_mode": "solution", + "previous_enabled": true, + "requested_enabled": false, + "applied": true, + "changed": true + } + }, + { + "program": 9, + "check": "invitation version conflict preserves recipient and free role" + }, + { + "program": 9, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 9, + "storage_mode": "solution", + "previous_enabled": false, + "requested_enabled": true, + "applied": true, + "changed": true + } + }, + { + "program": 9, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 9, + "storage_mode": "solution", + "previous_enabled": true, + "requested_enabled": false, + "applied": true, + "changed": true + } + }, + { + "program": 9, + "template": "case_championship", + "solution": 8, + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ] + }, + { + "program": 10, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 10, + "storage_mode": "solution", + "previous_enabled": false, + "requested_enabled": true, + "applied": true, + "changed": true + } + }, + { + "program": 10, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 10, + "storage_mode": "solution", + "previous_enabled": true, + "requested_enabled": false, + "applied": true, + "changed": true + } + }, + { + "program": 10, + "check": "invitation version conflict preserves recipient and free role" + }, + { + "program": 10, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 10, + "storage_mode": "solution", + "previous_enabled": false, + "requested_enabled": true, + "applied": true, + "changed": true + } + }, + { + "program": 10, + "check": "scoped CLI rollout/rollback", + "receipt": { + "program_id": 10, + "storage_mode": "solution", + "previous_enabled": true, + "requested_enabled": false, + "applied": true, + "changed": true + } + }, + { + "program": 10, + "template": "hackathon", + "solution": 10, + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ] + } + ], + "programs": { + "9": { + "solutionId": 8, + "template": "case_championship", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "creationDisabled": true, + "submitted": true, + "reenabledDraftId": 9, + "rollbackVerified": true, + "complete": true + }, + "10": { + "solutionId": 10, + "template": "hackathon", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "creationDisabled": true, + "submitted": true, + "reenabledDraftId": 11, + "rollbackVerified": true, + "complete": true + } + }, + "events": [ + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solution-context/", + "status": 200 + }, + { + "role": "captain-http-guard", + "method": "POST", + "path": "/programs/9/solutions/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/certificates/me/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/analytics-widget/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/news/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/acknowledge-welcome/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/8/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/9/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/9/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/9/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/9/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/programs/9/acknowledge-welcome/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/20/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/8/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/8/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/8/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/files/", + "status": 201 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/9/solutions/8/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/9/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/9/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/9/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/9/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/9/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/21/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/22/", + "status": 204 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/9/solutions/8/", + "status": 200 + }, + { + "role": "captain-http-repeat", + "method": "POST", + "path": "/programs/9/solutions/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/9/solution-context/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/9/solutions/", + "status": 201 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/9/solutions/9/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain-http-guard", + "method": "POST", + "path": "/programs/10/solutions/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/certificates/me/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/news/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/analytics-widget/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/acknowledge-welcome/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solution-context/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/10/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/10/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/10/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/10/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/10/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/programs/10/acknowledge-welcome/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/23/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/10/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/10/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/10/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/10/solutions/10/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/10/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/10/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/10/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/10/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/10/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/24/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/25/", + "status": 204 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/10/solutions/10/", + "status": 200 + }, + { + "role": "captain-http-repeat", + "method": "POST", + "path": "/programs/10/solutions/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/10/solution-context/", + "status": 200 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_b", + "method": "POST", + "path": "/programs/10/solutions/", + "status": 201 + }, + { + "role": "pending_b", + "method": "GET", + "path": "/programs/10/solutions/11/", + "status": 200 + } + ], + "javascriptErrors": [], + "widths": [ + 320, + 390, + 768, + 1440 + ], + "browser": "Headless Edge / Playwright", + "origin": "http://127.0.0.1:8875", + "api": "http://127.0.0.1:8871", + "buildProvenance": "C:/Users/evsee/AppData/Local/Temp/procollab-creation-angular/qa-build-provenance.json", + "resumedFromCompletedPhases": null, + "mutatingHttpRequests": 37, + "limitations": [ + "participant scope only; manager/public UI pending", + "full AA/screen reader and actual browser zoom not claimed", + "phase evidence and resumed final verification are distinguished; not claimed one uninterrupted final invocation", + "external network requests blocked; default remote avatars are absent in screenshots", + "mutatingHttpRequests includes expected conflicts; it is not a count of persisted mutations" + ] +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/build-provenance.json b/docs/qa/program-solutions-creation-switch-20261025/build-provenance.json new file mode 100644 index 00000000..f5af0d55 --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/build-provenance.json @@ -0,0 +1,28 @@ +{ + "source": "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\dist\\social_platform", + "target": "C:/Users/evsee/AppData/Local/Temp/procollab-creation-angular", + "frontendBase": "e57fb04ec93fdede611712014492a529cf9172b9", + "frontendState": "working tree with creation-switch error copy and C00 1.13; source hashes identify runtime input", + "changes": "only API/WebSocket origins in copied generated JS", + "changed": [ + { + "file": "chunk-CHX6T2XY.js", + "before": "4707c125db5485fa8b5eeb9e30e23c6f90759d9f2daf86b56a5a0b332da287dd", + "after": "9952a60a44e2cf169099556f3b81c6866212b5aa80784d253b7b1a627defd7bd" + } + ], + "sources": [ + { + "file": "projects/social_platform/src/app/api/solution/solution-navigation.ts", + "sha256": "f08e39b9feb74fbec0fb40056b1a680841f2fc382cf71d7b77b65fafe3a1195e" + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "sha256": "41f826edc9725830c287c7f13b1559419002a7ccef448509fe4baea2a04acf49" + }, + { + "file": "projects/social_platform/src/app/ui/widgets/detail/services/program/detail-program-info.service.ts", + "sha256": "08828f85f0a61eeb57b8aff4a510b70a45b2268edb146cc2053cb6db967ac739" + } + ] +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/contract-compatibility.json b/docs/qa/program-solutions-creation-switch-20261025/contract-compatibility.json new file mode 100644 index 00000000..c509933b --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/contract-compatibility.json @@ -0,0 +1,10 @@ +{ + "status": "PASS", + "version": "1.13.0", + "schemas": 120, + "operations": 45, + "scenarios": 161, + "prior158ScenariosUnchanged": true, + "allPriorSchemasRequestsResponseSchemasAndExamplesUnchanged": true, + "threeContractFilesByteIdentical": true +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/domain-evidence.json b/docs/qa/program-solutions-creation-switch-20261025/domain-evidence.json new file mode 100644 index 00000000..083d93f2 --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/domain-evidence.json @@ -0,0 +1,251 @@ +{ + "status": "PASS", + "programs": [ + { + "program_id": 9, + "template": "case_championship", + "submitted_solution_id": 8, + "reenabled_draft_solution_id": 9, + "final_creation_enabled": false, + "existing_read_and_permissions_retained": true, + "snapshot_and_zero_false_case_retained": true, + "files": [ + { + "size": 597, + "sha256": "4722e145f386e32649b51fb43c5d7a09b559fd06c22ba2d89708a612d6f8c99e" + } + ] + }, + { + "program_id": 10, + "template": "hackathon", + "submitted_solution_id": 10, + "reenabled_draft_solution_id": 11, + "final_creation_enabled": false, + "existing_read_and_permissions_retained": true, + "snapshot_and_zero_false_case_retained": true, + "files": [] + } + ], + "httpChecks": [ + { + "path": "/programs/9/solutions/8/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/8/", + "role": "member", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/8/", + "role": "manager", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/", + "role": "captain", + "method": "POST", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solution-context/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solution-context/", + "role": "pending_a", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/", + "role": "pending_a", + "method": "POST", + "status": 409, + "code": "solution_creation_disabled" + }, + { + "path": "/programs/9/solutions/9/", + "role": "pending_b", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/", + "role": "pending_b", + "method": "POST", + "status": 200, + "code": null + }, + { + "path": "/programs/9/solutions/8/", + "role": "anonymous", + "method": "GET", + "status": 401, + "code": "authentication_required" + }, + { + "path": "/programs/9/solutions/8/", + "role": "pending_a", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/9/solutions/8/", + "role": "pending_b", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/9/solutions/8/", + "role": "member", + "method": "PATCH", + "status": 403, + "code": "not_solution_captain" + }, + { + "path": "/programs/9/solutions/8/", + "role": "captain", + "method": "PATCH", + "status": 409, + "code": "team_frozen" + }, + { + "path": "/programs/9/solutions/8/submit/", + "role": "captain", + "method": "POST", + "status": 409, + "code": "already_submitted" + }, + { + "path": "/programs/10/solutions/10/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/10/", + "role": "member", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/10/", + "role": "manager", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/", + "role": "captain", + "method": "POST", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solution-context/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solution-context/", + "role": "pending_a", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/", + "role": "pending_a", + "method": "POST", + "status": 409, + "code": "solution_creation_disabled" + }, + { + "path": "/programs/10/solutions/11/", + "role": "pending_b", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/", + "role": "pending_b", + "method": "POST", + "status": 200, + "code": null + }, + { + "path": "/programs/10/solutions/10/", + "role": "anonymous", + "method": "GET", + "status": 401, + "code": "authentication_required" + }, + { + "path": "/programs/10/solutions/10/", + "role": "pending_a", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/10/solutions/10/", + "role": "pending_b", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/10/solutions/10/", + "role": "member", + "method": "PATCH", + "status": 403, + "code": "not_solution_captain" + }, + { + "path": "/programs/10/solutions/10/", + "role": "captain", + "method": "PATCH", + "status": 409, + "code": "team_frozen" + }, + { + "path": "/programs/10/solutions/10/submit/", + "role": "captain", + "method": "POST", + "status": 409, + "code": "already_submitted" + } + ], + "verificationDomainMutations": 0, + "preexistingDomainHashMatchesMigrationBaseline": true, + "preexistingDomainSha256": "068f4c6db8d2ed308343dd03e450efdda67f75755d1133871e56faa615de40d9", + "projectCountUnchanged": true, + "limitations": [ + "real browser intentionally mutated only synthetic rehearsal fixtures", + "local storage, no production CDN/mail/deploy", + "full QA01/REL01 still incomplete" + ] +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/local-artifacts.json b/docs/qa/program-solutions-creation-switch-20261025/local-artifacts.json new file mode 100644 index 00000000..2106235f --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/local-artifacts.json @@ -0,0 +1,110 @@ +{ + "localDirectory": "Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-creation-switch", + "helpers": [ + { + "file": "procollab-solution-creation-browser.cjs", + "sha256": "1677dc1d314da5a2409262bc6d63b818089eec5ae2b17fc503a486f6eb27fe17" + }, + { + "file": "procollab-solution-creation-verify.py", + "sha256": "1bbc7088f093c24c9deb25e202449c1d7494b98677eec80398bcdf835d313537" + }, + { + "file": "procollab-solution-creation-toggle.py", + "sha256": "8cc1ac288f2b4ffe1ebce112adee8fa4eb83c78d073a360b8bc2f0cb8d73e2da" + }, + { + "file": "procollab-solution-creation-seed.py", + "sha256": "467e8eb38ceb43e604eccc87aef794467c23887ebc7a26198ba6d693874f55f4" + }, + { + "file": "procollab-solution-creation-migrate-local.py", + "sha256": "762b2c0d4840fc5931d804a46b41231fca6a8a14a98058325a24f3e30778eb85" + }, + { + "file": "procollab-solution-creation-build.cjs", + "sha256": "f5eca6a58ec46095aa905d13cb8b9e7831f927fc092bf1ef018446329e824e82" + } + ], + "screenshots": [ + { + "file": "case_championship-confirmation-mobile.png", + "sha256": "9ae2f9b47c39105fc23897ba0eff61934bf33b06c8a70a1c05975057a71a687f" + }, + { + "file": "case_championship-creation-paused-mobile.png", + "sha256": "3a198fe2ca2aa97726757a2ccba0e8916f1cefe04f29cdb74fe20fee3f54294a" + }, + { + "file": "case_championship-entry-paused-mobile.png", + "sha256": "e2681d97aaea2475081b51f44cfd1d6f532d15215c920b95ab46dd87264d9378" + }, + { + "file": "case_championship-materials-desktop.png", + "sha256": "258d58f4be62fdc9e65b23142f4d71172d199469f20a7dd8a1b74c4994ec1731" + }, + { + "file": "case_championship-materials-mobile.png", + "sha256": "97811566514d95263c51ca7c18939ab68963d796c762bd52f3daf535f3204d02" + }, + { + "file": "case_championship-submitted-materials-mobile.png", + "sha256": "11e05db830115ced3757ec217a5f9124226e244ee6f1174c8d1716bb2c6f895c" + }, + { + "file": "case_championship-submitted-team-mobile.png", + "sha256": "6d13a128997bb388ebb5ca7b499ccbfad61bb3df9548ee14bd46c6098d883c54" + }, + { + "file": "case_championship-team-mobile.png", + "sha256": "25fdff02ec74f726f3e5456fcdf08221b1bf19ad81517098afaaa85b819dbeab" + }, + { + "file": "hackathon-confirmation-mobile.png", + "sha256": "03fa0e508aa5d68cc21429ce96dd527366ae4bac59ff9bb07ee03e52ad0f350b" + }, + { + "file": "hackathon-creation-paused-mobile.png", + "sha256": "3a198fe2ca2aa97726757a2ccba0e8916f1cefe04f29cdb74fe20fee3f54294a" + }, + { + "file": "hackathon-entry-paused-mobile.png", + "sha256": "85224fcc89ab8e136241100b2db28e9559de2eecb73571aecfaf837bafeb90cc" + }, + { + "file": "hackathon-materials-desktop.png", + "sha256": "b1daf10bb0fe99f7c5c5c64a5d773d1544ff98cdcb5bad09ac0c4e80b18cd94c" + }, + { + "file": "hackathon-materials-mobile.png", + "sha256": "bd067c0306ed91d36ad476f3cc7b1fc27861c4958507d06083ac11499d055b0b" + }, + { + "file": "hackathon-submitted-materials-mobile.png", + "sha256": "9a73031b1d36da4162fd946a96fc4bdd0bf3c6585821ee4c57b430d0bc203d21" + }, + { + "file": "hackathon-submitted-team-mobile.png", + "sha256": "b8831da779046313cf4d1dc71f97d30fa6fd9c30f976241ae59ea7069e96e23e" + }, + { + "file": "hackathon-team-mobile.png", + "sha256": "67d70d91bb3db815ff33fd1a5cc76ec0407f9bf1bc1ef8e8ec30ba6212e2ca09" + } + ], + "finalBackendSources": [ + { + "file": "partner_programs/models.py", + "sha256": "b74a813717a0d73790ed11d7d5dfb34fdebb307e4b42dfe13f2331904d0b9689" + }, + { + "file": "partner_programs/solution_api.py", + "sha256": "1807b55255b544cd9bf8e514912331eeffab9fba889efcd29de44a33f3d16b01" + }, + { + "file": "partner_programs/management/commands/set_solution_creation.py", + "sha256": "85542885bfedbbeccd0010d7cbce413ccfb7de5d509e52c9558c5206faabd11a" + } + ], + "finalReview": "browser journey preceded the final stale-program-save protection; 261 regression tests and 30 real HTTP checks ran after that fix and server restart" +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/migration-evidence.json b/docs/qa/program-solutions-creation-switch-20261025/migration-evidence.json new file mode 100644 index 00000000..400ae95f --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/migration-evidence.json @@ -0,0 +1,19 @@ +{ + "status": "PASS", + "migration": "0033 -> 0034", + "database": "local PostgreSQL 18 / procollab_ci", + "unchanged": { + "programs": 8, + "partner_programs.ProgramSolution": 6, + "partner_programs.ProgramSolutionAnswer": 23, + "partner_programs.ProgramSolutionMember": 10, + "partner_programs.SolutionInvitation": 5, + "project_rates.SolutionScore": 12, + "project_rates.SolutionExpertAssignment": 4, + "files.UserFile": 5, + "projects.Project": 2 + }, + "allCreationFlagsDisabled": true, + "allPriorColumnsAndDomainDataUnchanged": true, + "stateSha256": "068f4c6db8d2ed308343dd03e450efdda67f75755d1133871e56faa615de40d9" +} diff --git a/docs/qa/program-solutions-creation-switch-20261025/run-state.json b/docs/qa/program-solutions-creation-switch-20261025/run-state.json new file mode 100644 index 00000000..7ca804c5 --- /dev/null +++ b/docs/qa/program-solutions-creation-switch-20261025/run-state.json @@ -0,0 +1,53 @@ +{ + "prefix": "rel01-20261025-f7bd7145", + "programs": { + "9": { + "solutionId": 8, + "template": "case_championship", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "creationDisabled": true, + "submitted": true, + "reenabledDraftId": 9, + "rollbackVerified": true, + "complete": true + }, + "10": { + "solutionId": 10, + "template": "hackathon", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "creationDisabled": true, + "submitted": true, + "reenabledDraftId": 11, + "rollbackVerified": true, + "complete": true + } + }, + "rolloutStarts": { + "9": { + "initiallyDisabled": true, + "directNewRouteBlocked": true, + "programEntryBlocked": true, + "postStatus": 409 + }, + "10": { + "initiallyDisabled": true, + "directNewRouteBlocked": true, + "programEntryBlocked": true, + "postStatus": 409 + } + } +} diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/README.md b/docs/qa/program-solutions-dev-compatibility-20261025/README.md new file mode 100644 index 00000000..74e32f4b --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/README.md @@ -0,0 +1,25 @@ + + +# Совместимость решений с актуальным dev + +Статус: **локальные compatibility checks PASS; полный Linux CI и первый выпуск не завершены; не READY TO MERGE**. Существующие Project-программы не переключаются и не удаляются. Исправлен подтвержденный CI конфликт двух веток миграций: исходная nullable certificate0031 взята без изменений из dev, пустая0039 зависит от обеих веток. Исторические migration names/operations сохранены. + +Product BE `7cfe01be1c2caba38ad04fef0ab6d68147df1d02`, feature prior `b66cf6cbfbfaadf39753d90d847e7330f79efd9e`, observed dev BE `587a9155b9f53d9e50981a9939a7007c19fd6ecc`. FE `ca1b203a34fddedd6f7d7d6a45752073697dfd7c` не менялся; его observed dev `08414b794dd5b34de5471dddaa19cd0af7f4a167` содержит отдельные certificate changes. Prospective BE tree `2b93d6a99be4a1ac250912b59bea7b220ebf195f` получен git merge-tree/archive без branch merge/rebase/force-push. Его850 Python sources имеют manifest SHA в evidence; source checks не являются full merged runtime regression. + +## Проверки + +- **439 PostgreSQL tests /429.683s/OK без skips**: все решения и4 legacy publisher tests, включая3 новых migration tests. Затем test-only backend guard/portability исправление: **15 PostgreSQL /51.294s/OK без skips**, все3 observed pg_blocking_pids races и оба порядка миграций выполнены. Focused15 — subset, не прибавлять к439. [Точные targets](native-targets.txt). Wrapper изолирует только Windows file logger sink; домен/DB не мокируются. +- **SQLite15 /51.926s/OK,3 skips**: обе migration orders действительно выполнены; пропущены исключительно3 PostgreSQL lock races. Предыдущий general CI1809 дал ровно3 failures на vendor assertion этих race tests и69 skips; guard согласован с существующими lock suites. PostgreSQL assertions и lock observations сохранены. Это отдельная среда, не полная проверка backend. +- Black5/Flake8/Django check/makemigrations consistency PASS. В [окончательном prospective snapshot](prospective-source-final.json) также check/check models/makemigrations PASS: конфликтов нет, единственный leaf0039, pending main migration plan пуст. Эти проверки read-only; prospective runtime/PDF suite локально не объявляется пройденным. +- [Main forward rehearsal](main-migration-retention.json) применил ровно существующую upstream0031 и пустую0039. Все прежние поля/строки27 моделей совпали до/после: `74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6`. [Повторное readonly чтение](retention-final.json) после439 подтвердило тот же hash. Main не откатывался/сбрасывался; реальные program flags не менялись. Project4→4; CPUB/C2 snapshots/events и7 старых registration audits включены. В main0 PDF rows; непустые PDF/backfill/token/original rendered_batch доказаны isolated tests обоих порядков. +- C00 **1.21.0/156 schemas/59 operations/305 scenarios** и3 shared files byte-identical BE/FE. Полный contracts folder и исходная QA01 matrix byte-exact относительно предыдущего CPUB checkpoint. API/права/Angular код этой поправкой не менялись; прежние2955 FE tests/types/build относятся к зафиксированному FE, не к еще не собранному latest-dev FE. + +## Сохраненные неудачные попытки и ограничения + +Expanded native459 завершился access violation `0xC0000005` в WeasyPrint FontConfiguration до итогового результата. Standalone вызов без Django также падает, в том числе с собственными временными Mont fonts. Точная причина native DLL неизвестна. Renderer и его assertions не менялись,459 не объявляются PASS; нужны full certificate/backend tests в поддерживаемом Linux CI конкретного нового HEAD. Raw failed/probe logs сохранены вне Git; их hashes/причины в [evidence](evidence.json). + +Предыдущий [PostgreSQL CI40223628921](https://github.com/PROCOLLAB-github/api/actions/runs/40223628921) failed на двух leaf nodes; предыдущий [general CI40223626180](https://github.com/PROCOLLAB-github/api/actions/runs/40223626180) failed на3 SQLite/vendor assertions, Lint success. Failed log general CI получен через gh после Transport closed connector. Новая проверка начнется только после push; ее статус нужно читать по конкретному новому HEAD, старые runs не перезапускались. + +[INTEG01–06 spec/audit](../../product/program-solutions-dev-compatibility.md): graph и retention подтверждены; full Linux integration остается открытым. Старый CPUB report сохраняется как evidence своего checkpoint, его найденный graph defect исправлен этой следующей поправкой. Порядок rollout: совместимые models/migrations → upstream0031+empty0039 → backend/workers → frontend. Schema-down после записанной истории запрещен; creation-only rollback сохраняется. Новых merge/deploy/real program switches нет. + +Исходные26 AC не менялись:23 PASS, AC18/19 OPEN, AC21 отдельно BE10. D02/D03 approval и draft eligibility уже запрошены. Angular manager/public screens, полный QA01/REL01 и весь первый выпуск остаются в scope. Никакие credentials/JWT/private baseline rows не публикуются. diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/evidence.json b/docs/qa/program-solutions-dev-compatibility-20261025/evidence.json new file mode 100644 index 00000000..e24fcc38 --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/evidence.json @@ -0,0 +1,150 @@ +{ + "status": "COMPATIBILITY CHECKS PASS; full Linux CI pending; first release incomplete", + "productCommit": "7cfe01be1c2caba38ad04fef0ab6d68147df1d02", + "initialCompatibilityCommit": "9c89367f05f8051dff8d57857592168995f70e6e", + "previousCheckpoint": "b66cf6cbfbfaadf39753d90d847e7330f79efd9e", + "frontendUnchanged": "ca1b203a34fddedd6f7d7d6a45752073697dfd7c", + "observedBackendDev": "587a9155b9f53d9e50981a9939a7007c19fd6ecc", + "observedFrontendDev": "08414b794dd5b34de5471dddaa19cd0af7f4a167", + "prospectiveTree": "2b93d6a99be4a1ac250912b59bea7b220ebf195f", + "sourceManifestSha256": "8756137adcafe41c56ba1e7310d66b3bbb04107d917524a6a8428beb5e867edb", + "productSourceHashes": { + "docs/product/program-solutions-dev-compatibility.md": "4840a2daedebba3334815547d0810be590d0718fdfa82f4d3fb92e45fc0d20f5", + "partner_programs/certificate_models.py": "cd26cf9ed558eedeb6002ee204515d96cc09e333731169b5c5db4ae63c9a3a39", + "partner_programs/migrations/0031_programcertificate_rendered_batch.py": "2fe9364b189b5f7db866d67a77eb3d171a131f250215a621ac7bb280ec005e66", + "partner_programs/migrations/0039_merge_solution_certificate_branches.py": "d3a89c9060a5d477bfa6cc8ba88cbda239052d9f3a97f8acb04da4e944baefca", + "partner_programs/tests/test_solution_competitive_publication.py": "d42808ecf35cb47ab0c642b66f082331bd63977989bf5bd1f1a463b9ca201480", + "partner_programs/tests/test_solution_dev_migrations.py": "0fd8e91472abcf6b485befbb305f8636974de82996c09315b90c06ec11dd615f" + }, + "sharedContracts": { + "examples.json": "60b15cd3c8209a21a2ba68cb0a606061a5ec62773de3f43fd447437c1319cea8", + "openapi.json": "6ed6fccefcfdd862862c0b372d20f54ce14445d3acc7fc20b745970e04bf3566", + "README.md": "6dc80bae4062f523a1e88fcd3edbc849c3b1fe423e20ebb0b83ece836d5500f2" + }, + "contract": { + "version": "1.21.0", + "schemas": 156, + "operations": 59, + "scenarios": 305, + "unchangedSinceCPUB": true, + "original26ACByteExact": true + }, + "tests": { + "nativePostgreSQL": { + "tests": 439, + "result": "OK", + "seconds": 429.683, + "skips": 0, + "source": "9c89367; final product only test guard/vendor portability changes", + "includesLegacyPublisher": 4 + }, + "focusedFinalPostgreSQL": { + "tests": 15, + "result": "OK", + "seconds": 51.294, + "skips": 0, + "observedLockRaces": 3, + "bothMigrationOrders": true, + "subsetNotAdditionalTotal": true + }, + "focusedSQLite": { + "tests": 15, + "executed": 12, + "result": "OK", + "seconds": 51.926, + "skips": 3, + "skippedOnly": "PostgreSQL pg_blocking_pids races", + "bothMigrationOrdersExecuted": true + }, + "failedExpandedNative": { + "testsFound": 459, + "result": "NOT COMPLETED / NOT PASS", + "exitCode": -1073741819, + "where": "WeasyPrint FontConfiguration native access violation", + "standaloneReproduced": true, + "exactDllCauseUnknown": true, + "rendererAndItsAssertionsUnmodified": true + }, + "static": "Black5/Flake8/Django check/makemigrations PASS" + }, + "migrationPolicy": { + "copiedExistingUpstream0031": true, + "emptyMerge0039": true, + "noRenumbering": true, + "noOldOperationChanges": true, + "mainForwardOnly": true, + "noRealProgramSwitch": true, + "branchMerge": false, + "rebase": false, + "forcePush": false, + "deploy": false + }, + "ci": { + "previousPostgreSQL": { + "id": 40223628921, + "result": "failure", + "step": "Check migration consistency", + "cause": "two leaf nodes before compatibility fix" + }, + "previousGeneral": { + "id": 40223626180, + "result": "failure", + "tests": 1809, + "failures": 3, + "skips": 69, + "lint": "success", + "cause": "PostgreSQL-only CPUB races incorrectly executed on SQLite", + "logFallback": "gh after connector Transport closed" + }, + "newHead": "not pushed at packaging time; follow exact pushed HEAD separately" + }, + "logs": { + "native439": { + "name": "procollab-solution-dev-integration-solution-native.log", + "bytes": 420880, + "sha256": "ab218795dff7496659bfa26bf48610748cea189571f94072acd8f746e38abf6b" + }, + "focusedPostgreSQL15": { + "name": "procollab-solution-dev-integration-focused-final.log", + "bytes": 10221, + "sha256": "ba8bac4147bac1061e12f8d8a9fde476161b79aa17015c3065fc4fcfa97d7c49" + }, + "focusedSQLite15": { + "name": "procollab-solution-dev-integration-sqlite.log", + "bytes": 31909, + "sha256": "f152215eaea6aa135c0f9914b573354b7df85cef1bcb2f3a26b4b32b6c5171c7" + }, + "failedNative459": { + "name": "procollab-solution-dev-integration-native.log", + "bytes": 439112, + "sha256": "43809a49adfeb880b8ed3cbba572faa2b0f00c5c3ecbdc46fd09696535f645bd" + }, + "mainForward": { + "name": "procollab-solution-dev-integration-main.log", + "bytes": 1051, + "sha256": "4cffc916164136ef68dcdadbd0f22711b971799ffa0ae0df0989f419f23744ba" + }, + "readonlyRetention": { + "name": "procollab-solution-dev-integration-retention-final.log", + "bytes": 378, + "sha256": "8be9f55a6b27591657281125baeec08d8e7a46c6764cc17e65faddcd093cf484" + }, + "prospectiveFinal": { + "name": "procollab-solution-dev-integration-prospective-final.log", + "bytes": 433, + "sha256": "f4823746c798d916905e202b3c74fdff89aa10aca777c7696bcce1c3c4a6a34c" + }, + "oldGeneralCIFailure": { + "name": "procollab-solution-dev-old-general-ci-failed.log", + "bytes": 42467949, + "sha256": "4c25ee641b1c0276032e7066494a98df16382eda87b452fd5f64d0848bed4a0e" + } + }, + "limits": [ + "Full supported Linux certificate/backend suite pending", + "No new frontend code/build/browser journey in this compatibility patch", + "D02/D03 approvals and competitive unsent draft eligibility already pending", + "Original AC18/19 remain OPEN; AC21 separate BE10; complete first release still in scope", + "Main had no existing PDF rows; nonempty PDF/backfill retention proved by isolated native migration tests" + ] +} diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/main-migration-retention.json b/docs/qa/program-solutions-dev-compatibility-20261025/main-migration-retention.json new file mode 100644 index 00000000..56919739 --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/main-migration-retention.json @@ -0,0 +1,140 @@ +{ + "status": "PASS", + "database": "guarded localhost PostgreSQL CI", + "migrationsApplied": [ + { + "app": "partner_programs", + "name": "0031_programcertificate_rendered_batch", + "backwards": false + }, + { + "app": "partner_programs", + "name": "0039_merge_solution_certificate_branches", + "backwards": false + } + ], + "modelsPreserved": 27, + "hashBefore": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "hashAfter": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "oldColumnsRetained": true, + "models": { + "partner_programs.PartnerProgram": { + "count": 48, + "hash": "308db2556493ac7c8c989adfa0bd5ca077e6e6edc89784b8401697bd82b40fbb" + }, + "partner_programs.PartnerProgramField": { + "count": 320, + "hash": "a622aed182b162484f79d558b63cea1f3763fcc230e25ea7762c2b75bb150059" + }, + "partner_programs.PartnerProgramUserProfile": { + "count": 135, + "hash": "ceef94cd6ced45827655d53e85c011a2357d5c48e48c928c7e9207e6e1119b96" + }, + "partner_programs.PartnerProgramProject": { + "count": 3, + "hash": "dce4dc89533f63c2135d54aa703d406e7d04b6baebfad64e8d7449a319908cf7" + }, + "partner_programs.PartnerProgramFieldValue": { + "count": 2, + "hash": "416237656955b2207ef23cb5508dade0049e6e916cc20cd311aa46ea4644899c" + }, + "partner_programs.ProgramSolution": { + "count": 70, + "hash": "d454cb8314277a1284b1190cbef363390fce5a54dcba00b93e2966584a34a735" + }, + "partner_programs.ProgramSolutionAnswer": { + "count": 312, + "hash": "1ee34d6e366810e381a0bbf1f220897cdb27c26dbcba9d66eee296a131036a31" + }, + "partner_programs.ProgramSolutionMember": { + "count": 95, + "hash": "600c5db76bd07c73057bd6ead7cf2f96fc14e11a582bd6e82e991c7fb53f57f9" + }, + "partner_programs.SolutionInvitation": { + "count": 47, + "hash": "4e73688be7059ee868b90e9ebdcd1b0bd5e67e03f63fbe1fdf02013face712b9" + }, + "project_rates.SolutionScore": { + "count": 52, + "hash": "60781436579b665a926c05c826decac12b897cedf43befb9fe17972ba6a96171" + }, + "project_rates.SolutionExpertAssignment": { + "count": 20, + "hash": "0717c98555e2d2cf761e088c39816d1f05a020dbc51b52b5e56fd20de47bb15f" + }, + "project_rates.Criteria": { + "count": 91, + "hash": "4221acbca6c7a4e49b0748ceda9d842fefe6e9a01449c7417136538cefbed5be" + }, + "users.Expert": { + "count": 10, + "hash": "673c050537bf88d48674456a8283d0c6e106355a90ff1933f45ce357f10b2dcc" + }, + "files.UserFile": { + "count": 11, + "hash": "844a7ac2122b834d1ad281deded3474cc377cdfbe866037a96e11980c7d0e141" + }, + "projects.Project": { + "count": 4, + "hash": "39f1962a4d9c6ba6b94d626ba944faff0ba1638d47bece52a33ea1f10e877de6" + }, + "projects.Collaborator": { + "count": 6, + "hash": "445691a8d2010219deb06e9fa548a523daff58e5735c48547acaeacc66ab3061" + }, + "invites.Invite": { + "count": 4, + "hash": "450c2b93a53c67c0354f46a7fa73a88bafe09e0939aa0e904c2f572843958146" + }, + "project_rates.ProjectScore": { + "count": 3, + "hash": "3440d0ca3408c76fb67a2d422a0c1cbdf0052767a91f868d1e1b60f4e671c9a3" + }, + "project_rates.ProjectExpertAssignment": { + "count": 1, + "hash": "f8bd082760b34f9b9847593c27541c0a3c9e1bb55d6c9cab45d8b7531bb996d4" + }, + "industries.Industry": { + "count": 1, + "hash": "9c35d62f8b66283b78118a279324d4a04f905e965f46d9a3e9665a020fb532e2" + }, + "users.Expert_programs": { + "count": 36, + "hash": "7123f7d8aff60859ae5d11e0c40bb2aa2c3e40ac0915a78fdf830a3ade7e2c52" + }, + "partner_programs.PartnerProgram_managers": { + "count": 52, + "hash": "dcabadee2fd446601d64c720e7d547c30d4234ba0ed5f17adf98abee62fb5b19" + }, + "partner_programs.SolutionAdministrativeChange": { + "count": 22, + "hash": "33d451a2fdaf0372f28274a23bbb0985a1fe73142f1d7290bcd3a8806358ae8e" + }, + "partner_programs.SolutionRegistrationRemoval": { + "count": 7, + "hash": "f44a714d07b290c0426cf9e8d67976fc6780f2c102e2ba204fe3ac27bdfbf79c" + }, + "users.CustomUser": { + "count": 33, + "hash": "824038a33873b26b1a67c890398eb2f84cb57d17444f087105f42249ff049475" + }, + "partner_programs.ProgramCertificate": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "partner_programs.ProgramCertificateBatch": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + }, + "certificatePDFRows": 0, + "certificateBackfillConsistent": true, + "singleLeaf": [ + "partner_programs", + "0039_merge_solution_certificate_branches" + ], + "noSchemaDown": true, + "noMainReset": true, + "noRealProgramModeOrCreationChanges": true, + "CPUBAndC2Included": true +} diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/native-targets.txt b/docs/qa/program-solutions-dev-compatibility-20261025/native-targets.txt new file mode 100644 index 00000000..3c91dabf --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/native-targets.txt @@ -0,0 +1,33 @@ +partner_programs.tests.test_solution_access_boundaries +partner_programs.tests.test_solution_administrative_history +partner_programs.tests.test_solution_answers +partner_programs.tests.test_solution_archive_actions +partner_programs.tests.test_solution_case_actions +partner_programs.tests.test_solution_case_analytics +partner_programs.tests.test_solution_competitive_publication +partner_programs.tests.test_solution_creation_switch +partner_programs.tests.test_solution_dev_migrations +partner_programs.tests.test_solution_entrypoints +partner_programs.tests.test_solution_expert_reminders +partner_programs.tests.test_solution_field_filters +partner_programs.tests.test_solution_forms +partner_programs.tests.test_solution_foundation +partner_programs.tests.test_solution_invitation_roles +partner_programs.tests.test_solution_invitations +partner_programs.tests.test_solution_manager_analytics +partner_programs.tests.test_solution_member_actions +partner_programs.tests.test_solution_participant_reminders +partner_programs.tests.test_solution_program_routing +partner_programs.tests.test_solution_publication +partner_programs.tests.test_solution_registration_actions +partner_programs.tests.test_solution_role_widget +partner_programs.tests.test_solution_submission +partner_programs.tests.test_solution_submission_actions +partner_programs.tests.test_solution_team +project_rates.tests.test_solution_admin +project_rates.tests.test_solution_allocation +project_rates.tests.test_solution_cabinet +project_rates.tests.test_solution_evaluation +project_rates.tests.test_solution_results +project_rates.tests.test_solution_results_export +partner_programs.tests.test_services diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/prospective-source-final.json b/docs/qa/program-solutions-dev-compatibility-20261025/prospective-source-final.json new file mode 100644 index 00000000..939f0261 --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/prospective-source-final.json @@ -0,0 +1,73 @@ +{ + "status": "PASS", + "productCommit": "7cfe01be1c2caba38ad04fef0ab6d68147df1d02", + "observedDev": "587a9155b9f53d9e50981a9939a7007c19fd6ecc", + "prospectiveTree": "2b93d6a99be4a1ac250912b59bea7b220ebf195f", + "sourceKind": "git merge-tree/archive, no branch merge", + "sourceFiles": 850, + "sourceManifestSha256": "8756137adcafe41c56ba1e7310d66b3bbb04107d917524a6a8428beb5e867edb", + "selectedSources": { + "partner_programs/certificate_models.py": "cd26cf9ed558eedeb6002ee204515d96cc09e333731169b5c5db4ae63c9a3a39", + "partner_programs/services/certificates.py": "2cfb31977e870f673a12a7b39f028f1471c78ea7fa570002860a9ac32b41bc20", + "partner_programs/models.py": "9b9ecd526ccbf937a1020e82a15d1d2e61c39a21e9ac117a0e0b33bb68a630b4", + "partner_programs/solution_models.py": "af30b98eb2bda77bea9ffbc5c4e33e2f2479d9c2bc9e6c301013416efc762803", + "partner_programs/solution_publication_api.py": "01e36a9dc0b37d62fb2921a26d9d59e60097c3dcfddcc909b6a99cd59c677f87", + "partner_programs/services/publishing.py": "ae5d647da02cc4e1e246c13e23c4121c6a979cf7144811449be1ead5af592044", + "partner_programs/services/solution_publication.py": "48f21b691c2d880d30bac0b18d08b15545edf9e07967ea717eeb7f2e2f4451bf", + "partner_programs/tests/test_solution_dev_migrations.py": "7d5241f207cbb365606ad9cf84084ef726e2b48b7c373b655d15adfa8a0d930a" + }, + "migrationSources": { + "partner_programs/migrations/0001_initial.py": "fdece50ed163e2218266b80e7a9798fcc9e235ba72dde8d36d88413da97342fc", + "partner_programs/migrations/0002_alter_partnerprogram_options_and_more.py": "e01079d6f52fae9b43f352acc2699bc1054ae5dd527a3d8b5277fe85959e23d5", + "partner_programs/migrations/0003_partnerprogramuserprofile_project_and_more.py": "c79a9b0d550a38bd6df01b024f90e178791ff3a258e1cfd0eb53ee77b5140421", + "partner_programs/migrations/0004_auto_20231230_0002.py": "50bed222cc172cab1881d776624b34fdd8953f401e8fa62bf765173f59afc933", + "partner_programs/migrations/0005_partnerprogram_draft.py": "23b7ecf3cc66c74a60a19a5a2995994a0ca7e7e5ded47a9da1a653832cf97330", + "partner_programs/migrations/0006_partnerprogram_projects_availability.py": "f25d59ff1a09bf96b6b975c300c9d92678dbd46c281687c26c257becf856c419", + "partner_programs/migrations/0007_partnerprogrammaterial.py": "4fe80d4de11f2009828aa0308fde15ea41f30c7e14724d324fba2c81cc5da3c8", + "partner_programs/migrations/0008_partnerprogram_managers_and_more.py": "4b4dbf2bffff72532fde6bbaae888e8f5ec16aacf1cf18589d8109234fccf8a3", + "partner_programs/migrations/0009_partnerprogramfield_partnerprogramproject_and_more.py": "3a426d84d608c9e96203ace5a3e9928d14e60d17459d287c6b2d97df68490307", + "partner_programs/migrations/0010_alter_partnerprogramfield_options_and_more.py": "99cf939ed193497cf754a59d5f79b19869d80613691fce83d4ffc868df3f2f5e", + "partner_programs/migrations/0011_partnerprogram_is_competitive_and_more.py": "818e1b8657fb8f3d0601536fae85ac5ab4f13ebf6c02c0e66389d131db45dc8d", + "partner_programs/migrations/0012_partnerprogram_registration_link.py": "8ce2696cbfa1ae01a35fde44cb94eff43946356d5d9f7fd69e96dd7555aeaef8", + "partner_programs/migrations/0013_partnerprogram_max_project_rates.py": "db52fee8d2b55ed8fe3c041a497d76a534acc159cc99d57daab32fe5e148ebce", + "partner_programs/migrations/0014_partnerprogram_datetime_evaluation_ends_and_more.py": "7037560b09257b4bb30329b8782db1304e03b7b5548c379ac38ceb204e246d20", + "partner_programs/migrations/0015_partnerprogram_publish_projects_after_finish.py": "3a2f26ee75fcfad8e89c1457d8e8f109eddb406b4458942abc7551e07fda7c63", + "partner_programs/migrations/0016_partnerprogram_is_distributed_evaluation.py": "7f4c2ddfdeda33b99093a5de5cb51e2d993938302363949782d2d2f143e0bcc9", + "partner_programs/migrations/0017_alter_partnerprogramproject_options.py": "c18f540590d5f11c6431c0c6a9ece04161c36d53b1ef998b1b68071b2340abf6", + "partner_programs/migrations/0018_partnerprogramuserprofile_welcome_acknowledged_at.py": "cf5eb0f604b5a4870e2d5b5ffd46917de35d54d50ce2a486c8f67357de19cadb", + "partner_programs/migrations/0019_legacy_project_team_policy.py": "989ff9a120602e3b3358dcf0b50a2457cffa94724c018462d3ad31d1162ae810", + "partner_programs/migrations/0020_partnerprogram_image_framing.py": "f55fadc0b08a6e8e8cc532b4007cacd1a8d1ef977e8041276077a9e19f2983d3", + "partner_programs/migrations/0021_evaluation_criteria_lock.py": "17a273ea6d9427172109710ef7edcbf1fb55e18a54ff764bba846aebf67a0e36", + "partner_programs/migrations/0022_partnerprogram_required_evaluators.py": "3d25eaa9853c2407680e2771fc8376342886cb3111c2cd5b9d6a6d37548537a3", + "partner_programs/migrations/0023_organizerinvitation_and_more.py": "62cddf022860fbc13fc241eba6f0e0ba3a2808864d92584272cb1f03350ba7a4", + "partner_programs/migrations/0024_partnerprogramuserprofile_registration_schema_and_more.py": "2078b4094c5c3f7b4b55d11434b5f8395e609285171082cf617f5ec4805dbeae", + "partner_programs/migrations/0025_registration_connection_details.py": "e1353572a2acbefd3caf59a4a5d92c671a660236ea26576ab72d9f2650483704", + "partner_programs/migrations/0026_interface_copy.py": "650dc9f0c1b9efd06620bc761709802e72c4f8ed636df826cfe5515de2cf781b", + "partner_programs/migrations/0027_registration_request_notifications.py": "509e438b7a1b9a25e28f144dfc58adb08c297a26765b9efe0f10cfcaaa642a32", + "partner_programs/migrations/0028_programattentionreminderbatch_and_more.py": "1ad05218fd2c3ba15681e7b26aac0a7c9fc5ded9593269a6356cde2beffec2e3", + "partner_programs/migrations/0029_programcertificateasset_programcertificateconfig_and_more.py": "f009d8e4bd9336a1c5eaac26b64ff7b5581b9fef90dbaae9e38de5fc967b0e47", + "partner_programs/migrations/0030_program_solution_foundation.py": "fdb2b9d89f5758df0bfdf0ddf1a78f5d0c5c98d309f7beeb913a02bda0a2aa9b", + "partner_programs/migrations/0031_programcertificate_rendered_batch.py": "2fe9364b189b5f7db866d67a77eb3d171a131f250215a621ac7bb280ec005e66", + "partner_programs/migrations/0031_solution_field_options.py": "615ab165e9ad29c7adcb161a5b67ad4bdf2ef5332f85261b6a25ac39c72fecd5", + "partner_programs/migrations/0032_alter_programsolution_options.py": "daabc62c0c2595ae7d3a04ccde79f5942b597db2623b6813fe8fc948421b4dee", + "partner_programs/migrations/0033_solution_publication.py": "1724fb7a93f9d1cff87e635a632ad9ffcee7f06541c4b9ae122cad3483347f23", + "partner_programs/migrations/0034_solution_creation_switch.py": "c41268f008f33a6721c577f31bd8c3405f1095c0a3e937aedbdd3b771b280da9", + "partner_programs/migrations/0035_solution_administrative_history.py": "f5469f416cd351d2a80d44264090c7e1de24ae4293617e0c893361732098b0d8", + "partner_programs/migrations/0036_solution_member_admin_history.py": "59e493a8c769b8014ee25003a91b66e95ce00f79f80de8b171fe685b9e39a802", + "partner_programs/migrations/0037_solution_archive_retention.py": "3e5f92253435277705ceab5a69e1c25e894153d56eb484d1df51eaadfd7d75db", + "partner_programs/migrations/0038_solution_registration_removal_audit.py": "a55958f8662cfb5b3d3fb55cfe5ee76d0ee6ff5d83595e206dcc84ac77a2017e", + "partner_programs/migrations/0039_merge_solution_certificate_branches.py": "f9908fb8f247f677a64a3aa4929ffae6d16f7ce372d4d10d64166b9001caa100", + "partner_programs/migrations/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "conflicts": {}, + "partnerProgramLeaves": [ + ["partner_programs", "0039_merge_solution_certificate_branches"] + ], + "modelAndConfigurationChecks": "PASS", + "makemigrationsCheck": "No changes detected", + "mainPendingPlan": [], + "databaseMutation": false, + "branchMerge": false, + "frontendNotRebuilt": true, + "pdfRendererNotExercised": true +} diff --git a/docs/qa/program-solutions-dev-compatibility-20261025/retention-final.json b/docs/qa/program-solutions-dev-compatibility-20261025/retention-final.json new file mode 100644 index 00000000..0fef3222 --- /dev/null +++ b/docs/qa/program-solutions-dev-compatibility-20261025/retention-final.json @@ -0,0 +1,121 @@ +{ + "status": "PASS", + "mode": "readonly after final solution regression", + "modelsPreserved": 27, + "hashBefore": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "hashAfter": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "models": { + "partner_programs.PartnerProgram": { + "count": 48, + "hash": "308db2556493ac7c8c989adfa0bd5ca077e6e6edc89784b8401697bd82b40fbb" + }, + "partner_programs.PartnerProgramField": { + "count": 320, + "hash": "a622aed182b162484f79d558b63cea1f3763fcc230e25ea7762c2b75bb150059" + }, + "partner_programs.PartnerProgramUserProfile": { + "count": 135, + "hash": "ceef94cd6ced45827655d53e85c011a2357d5c48e48c928c7e9207e6e1119b96" + }, + "partner_programs.PartnerProgramProject": { + "count": 3, + "hash": "dce4dc89533f63c2135d54aa703d406e7d04b6baebfad64e8d7449a319908cf7" + }, + "partner_programs.PartnerProgramFieldValue": { + "count": 2, + "hash": "416237656955b2207ef23cb5508dade0049e6e916cc20cd311aa46ea4644899c" + }, + "partner_programs.ProgramSolution": { + "count": 70, + "hash": "d454cb8314277a1284b1190cbef363390fce5a54dcba00b93e2966584a34a735" + }, + "partner_programs.ProgramSolutionAnswer": { + "count": 312, + "hash": "1ee34d6e366810e381a0bbf1f220897cdb27c26dbcba9d66eee296a131036a31" + }, + "partner_programs.ProgramSolutionMember": { + "count": 95, + "hash": "600c5db76bd07c73057bd6ead7cf2f96fc14e11a582bd6e82e991c7fb53f57f9" + }, + "partner_programs.SolutionInvitation": { + "count": 47, + "hash": "4e73688be7059ee868b90e9ebdcd1b0bd5e67e03f63fbe1fdf02013face712b9" + }, + "project_rates.SolutionScore": { + "count": 52, + "hash": "60781436579b665a926c05c826decac12b897cedf43befb9fe17972ba6a96171" + }, + "project_rates.SolutionExpertAssignment": { + "count": 20, + "hash": "0717c98555e2d2cf761e088c39816d1f05a020dbc51b52b5e56fd20de47bb15f" + }, + "project_rates.Criteria": { + "count": 91, + "hash": "4221acbca6c7a4e49b0748ceda9d842fefe6e9a01449c7417136538cefbed5be" + }, + "users.Expert": { + "count": 10, + "hash": "673c050537bf88d48674456a8283d0c6e106355a90ff1933f45ce357f10b2dcc" + }, + "files.UserFile": { + "count": 11, + "hash": "844a7ac2122b834d1ad281deded3474cc377cdfbe866037a96e11980c7d0e141" + }, + "projects.Project": { + "count": 4, + "hash": "39f1962a4d9c6ba6b94d626ba944faff0ba1638d47bece52a33ea1f10e877de6" + }, + "projects.Collaborator": { + "count": 6, + "hash": "445691a8d2010219deb06e9fa548a523daff58e5735c48547acaeacc66ab3061" + }, + "invites.Invite": { + "count": 4, + "hash": "450c2b93a53c67c0354f46a7fa73a88bafe09e0939aa0e904c2f572843958146" + }, + "project_rates.ProjectScore": { + "count": 3, + "hash": "3440d0ca3408c76fb67a2d422a0c1cbdf0052767a91f868d1e1b60f4e671c9a3" + }, + "project_rates.ProjectExpertAssignment": { + "count": 1, + "hash": "f8bd082760b34f9b9847593c27541c0a3c9e1bb55d6c9cab45d8b7531bb996d4" + }, + "industries.Industry": { + "count": 1, + "hash": "9c35d62f8b66283b78118a279324d4a04f905e965f46d9a3e9665a020fb532e2" + }, + "users.Expert_programs": { + "count": 36, + "hash": "7123f7d8aff60859ae5d11e0c40bb2aa2c3e40ac0915a78fdf830a3ade7e2c52" + }, + "partner_programs.PartnerProgram_managers": { + "count": 52, + "hash": "dcabadee2fd446601d64c720e7d547c30d4234ba0ed5f17adf98abee62fb5b19" + }, + "partner_programs.SolutionAdministrativeChange": { + "count": 22, + "hash": "33d451a2fdaf0372f28274a23bbb0985a1fe73142f1d7290bcd3a8806358ae8e" + }, + "partner_programs.SolutionRegistrationRemoval": { + "count": 7, + "hash": "f44a714d07b290c0426cf9e8d67976fc6780f2c102e2ba204fe3ac27bdfbf79c" + }, + "users.CustomUser": { + "count": 33, + "hash": "824038a33873b26b1a67c890398eb2f84cb57d17444f087105f42249ff049475" + }, + "partner_programs.ProgramCertificate": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "partner_programs.ProgramCertificateBatch": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + }, + "databaseMutation": false, + "mainReset": false, + "realProgramSwitch": false, + "allPreviouslyPresentColumnsIncluded": true +} diff --git a/docs/qa/program-solutions-draft-acceptance-20261025/README.md b/docs/qa/program-solutions-draft-acceptance-20261025/README.md new file mode 100644 index 00000000..ce96721e --- /dev/null +++ b/docs/qa/program-solutions-draft-acceptance-20261025/README.md @@ -0,0 +1,32 @@ +# QA01 — форма, типы ответов и неизменная сдача + +25.10.2026. Подтверждены AC10/13/14/15/16/17 в указанном scope; **общий QA01 и REL01 остаются открытыми**. [Исходная матрица](../../product/program-solutions-qa01.md), [каноническое ТЗ](https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175). [Source/artifact evidence](evidence.json), [новые HTTP/XLSX проверки](http-evidence.json). + +## Критерии и доказательство + +| AC | Подтвержденное поведение | Конкретное evidence | +| --- | --- | --- | +| AC10 | Кейс не выбирается автоматически; missing/retired выбор блокирует сдачу | PART/FORM/NEG actual empty select с null; NEG01 переводит к тому же case control. Текущий test_incomplete_case_and_invalid_choice_are_controlled_and_atomic: missing дает400/case_required с field_id, устаревший выбор400/case_unavailable; оба сохраняют draft без snapshot. Контролы/методы подготовки и фокуса совпадают с проверенным FORM ref | +| AC13 | Отсутствующий ответ, число0 и boolean false различаются в edit/read UI, JSON и XLSX | FORM05 actual save/reload/tabs трех форматов; PART readonly0/«Нет». Новый current GET дает именно int0/bool false, empty optional отсутствует в answers. В трех настоящих XLSX0 имеет cell.data_type=n, false — b; empty optional не имеет answer row, его definition присутствует. Текущие field-filter tests проверяют present/missing/not_applicable, null/empty/whitespace и типы ячеек | +| AC14 | Неполнота/закрытый срок не меняют статус, UI указывает причину | NEG01/02 missing team/case/PDF/repository открывает точную вкладку/control без submit POST. NEG03 закрывает срок после сохранения/подтверждения: actual POST409/submission_closed, draft остается, последующий PATCH200 разрешен. Текущие submission tests проверяют required/constraints, deadline priority/inclusive boundary и rollback. Обычный submit вызывает require_open и второй deadline recheck перед snapshot; late admin path отдельно | +| AC15 | Одновременная сдача и правка ответов/состава не меняют сданное содержимое | Шесть existing TransactionTestCase повторно прошли на native PostgreSQL с независимыми connections/Barrier: submit×submit/PATCH/remove/accept/form/file-delete. Прочитаны точные allowed ordered outcomes и assertions snapshot/name/roster/file. Shared program→solution row locks и atomic защищают те же writers; тесты не заменялись SQLite | +| AC16 | Устаревшая форма обновляется без потери локального ввода; required применяется к несданной работе | NEG05 настоящий manager PUT + PATCH409/form_revision_conflict + GET/rebase сохраняет name/text/0/false/case, отличающиеся от server13/True/«Город». Новое required поле пустое и фокусируется при сдаче draft из Team. Сданный anchor остается готовым. Current facade/control sources и save/prepare/confirm/error/focus methods совпадают с FORM runtime; source hashes проверены | +| AC17 | Изменение labels/order/required не меняет просмотр ранее сданной работы | NEG06 actual manager PUT и anchor comparison; текущий snapshot_survives_form_labels_order_and_new_required_field сравнивает form/answers после reorder/rename/new required. Новый GET anchors12/13 в программах11/12 совпал с исходными form/answers/members, хотя current form имеет другие definitions/revision и required82/83; submitted readiness остается true | + +## Новая текущая проверка + +Runtime backend db7ffe4d6124b16232685fb49f77105f1436bacf, frontend f6f1ba96f369d06d502b250b98f3fbcf0963309c, C001.16.0. Product source/UI/contracts в этом checkpoint не менялись. + +Реальный HTTP/JWT/Django/PostgreSQL:7 C00 JSON GET и3 XLSX GET. Программы27/28/29, решения42/43/44 проверяют empty/0/false в JSON и четырехлистных книгах. Сданные anchors12/13 программ11/12 сравниваются с первоначальным snapshot и актуальной формой программы. Все15 выбранных domain моделей до/после совпали, новых fixtures и domain writes нет. Auth/user last_activity не входит в эту доменную выборку. Книги и raw wire сохранены в `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-draft-acceptance`; hashes в evidence. Проверка чтения не выдается за повторную сдачу или новый browser rebase. + +Шесть existing concurrency tests запущены отдельным focused прогоном с verbosity2 на `test_procollab_ci_unicode`, native PostgreSQL/has_select_for_update=True;6/6 OK без skips. Это подмножество ранее прошедшего current340 solution suite, а не346 различных tests. File-delete test изолирует внешний CDN delete стандартным stub; транзакции, row locks, ORM и тестовая БД настоящие. В source audit прочитаны общие locks и варианты при выигрыше каждого writer. Не заявляется нагрузочное тестирование или перебор всех возможных расписаний. + +Предыдущий current frontend2843/480+strict types/build/lint/locked format PASS сохраняет свой source ref: фронт не менялся и полный прогон заново не запускался. Source audit подтвердил5 целых core files и6 методов ProgramSolutionComponent относительно FORM37ecd71d: facade, field renderer/template, field controls/value logic, save/prepare/confirm/error/focus/issue labels. Account invalidation из ACCESS сохраняется; constructor целиком не объявляется прежним. + +## Происхождение и ограничения + +- [NEG](https://github.com/PROCOLLAB-github/procollab/blob/f6f1ba96f369d06d502b250b98f3fbcf0963309c/docs/qa/program-solutions-negative-20261025/README.md) сохраняет actual пустую форму/дедлайн/rebase/snapshot проверки и отдельное исправление stale network alert. Ранний общий browser отчет FAIL из-за stale alert не объявляется зеленым; конкретные завершенные фазы/state/raw attempts отделены от более позднего post-fix прохода. +- [FORM](https://github.com/PROCOLLAB-github/procollab/blob/f6f1ba96f369d06d502b250b98f3fbcf0963309c/docs/qa/program-solutions-form-settings-20261025/README.md) подтверждает actual UI empty/0/false/reload трех форматов. Baseline снят после setup; catalog mutation выполнялась только в verifier process. Новые XLSX checks закрывают оставшийся book scope AC13. +- Проверены48 hashes прежних helpers/raw artifacts; NEG копии JSON равны raw по значениям. FORM summaries намеренно curated: удаляют raw event forms и добавляют отдельно проверенный final read; их равенство полному raw не заявляется. Начальное ожидание package helper полного равенства исправлено по manifest curation note; продуктовые данные не менялись. +- Новый package helper сначала ожидал сокращенное имя unittest без method suffix; regex исправлен по существующему verbosity2 логу. Тесты не перезапускались ради изменения парсера; сохраненный лог6/OK и имена шести tests проверены отдельно. Эти ошибки упаковки не являются дефектами продукта. +- Результат не заменяет D02/D03 approval, manager/results/public UI, privileged post-submission/retention/detach/removal policy, competitive publication, ordinary-project/legacy полные пути или REL01. Full desktop/a11y/zoom/production CDN здесь не проверялись. Merge/deploy/переключения реальных программ нет. diff --git a/docs/qa/program-solutions-draft-acceptance-20261025/evidence.json b/docs/qa/program-solutions-draft-acceptance-20261025/evidence.json new file mode 100644 index 00000000..c3dcace3 --- /dev/null +++ b/docs/qa/program-solutions-draft-acceptance-20261025/evidence.json @@ -0,0 +1,385 @@ +{ + "status": "PASS", + "backendRuntime": "db7ffe4d6124b16232685fb49f77105f1436bacf", + "frontendRuntime": "f6f1ba96f369d06d502b250b98f3fbcf0963309c", + "c00": "1.16.0", + "backendSources": { + "partner_programs/solution_submission.py": "01518f2cd0cf6ec40de2b392f51bfc01d0da599680a5c7219f5ff69f1465a393", + "partner_programs/solution_answers.py": "cf088fedc7882e3cd4cb029bc3e9bb0bb056f6c8df30c3109f567b6d38a90e45", + "partner_programs/solution_team.py": "38b836a54d19dc6c2b1f8f9b02db8ba5f573a44bba08ad6d0aa0e542abb14c17", + "partner_programs/solution_forms.py": "d405d2251289d1784c02cab557fea9776ccefbf9e16adc61001a50bd2d2114b9", + "partner_programs/solution_api.py": "1807b55255b544cd9bf8e514912331eeffab9fba889efcd29de44a33f3d16b01", + "partner_programs/services/solution_program_report.py": "db1287dcb2711dcb5d0a5effc76950144910085fa7a2ac91510c9c939746faea", + "partner_programs/tests/test_solution_submission.py": "27ac6074dec2d6ca60d1d97b2643dc10d8427c4311dc570c08b7fe010eac09d0", + "partner_programs/tests/test_solution_field_filters.py": "01d02cf7845abf8d24651a477bd41b40320e4a946ee3528f36647daad4adcac6" + }, + "frontendSourceAudit": { + "status": "PASS", + "reference": "37ecd71d312ff7b8d81849329fcd92fb9f52e6a7", + "files": [ + { + "file": "projects/social_platform/src/app/api/solution/facades/solution-draft.facade.ts", + "normalizedSha256": "cdde59541e1f7b79e3bb3b1f49d89c29ec19aaf887563261763d19447a8f71e3", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/widgets/solution-fields/solution-fields.component.ts", + "normalizedSha256": "79f3fa6c2c166cb90615971debd619cfa852b89dc46176ff37cefdd376e8de1f", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/widgets/solution-fields/solution-fields.component.html", + "normalizedSha256": "aa7ea0361cb18f19175e591b922ad3edbe1dcab96b4d112bb447a5c75e0bf114", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/utils/solution-field-controls.ts", + "normalizedSha256": "18e64b5d1f4b0b60547ded7eeff24d3e0a0a55690297da54a2d7130c01074504", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/domain/solution/solution-values.ts", + "normalizedSha256": "486637dd039ce49a47bb459b6e26feef845f5a98f1975b65b70e4d0f13bfbcda", + "unchanged": true + } + ], + "methods": [ + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "saveDraft", + "normalizedSha256": "b3baa4d75d8b757e50b462e6ac1d26cc172ff5e40109255e197222433e9bd56b", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "prepareSubmission", + "normalizedSha256": "52ba880f2d5d7c05eda6a97d98e798be49abf2786d1068f39fd9aee83c99a05a", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "confirmSubmission", + "normalizedSha256": "6d6dbc73b4962f885e918e39b0e6ad7ce3802de6abfeeb68c6c6fd157500d9df", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "handleCommandError", + "normalizedSha256": "ebcc21147e340ddbc926784ad2b25a8311ca5bbd2e4a7093fe18b0e138d449f3", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "focusIssue", + "normalizedSha256": "60a864fae36aba0330d0238a210c94659df674f59d70f1747a259f68ae6e4b68", + "unchanged": true + }, + { + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution.component.ts", + "name": "issueLabel", + "normalizedSha256": "f7276e0b5ed196a1dfc7660cbdff23a69a66d476a5f574c459933dbd679d8627", + "unchanged": true + } + ] + }, + "nativePostgresRaceTests": [ + "test_file_delete_and_submit_share_the_file_lock", + "test_submit_and_accept_never_add_a_member_to_the_frozen_roster", + "test_submit_and_form_change_either_conflict_or_keep_the_old_snapshot", + "test_submit_and_patch_serialize_without_changing_snapshot", + "test_submit_and_remove_keep_one_accepted_roster", + "test_two_submits_make_exactly_one_snapshot" + ], + "nativePostgresRaceCount": 6, + "nativePostgresRaceLogSha256": "25100f647b638a2e8a9092424eacb8eaf8b9c674090f4b7326770ece2a2ef10a", + "existingFullBackendSuite": 340, + "existingFullFrontendSuite": 2843, + "existingFrontendFiles": 480, + "verifiedHistoricalArtifacts": [ + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-negative-seed.py", + "sha256": "12fcce6c2e279eed973e1a9f9ee931c99cb454359dd30dbc06c8e9534a5a0bf3" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-negative-deadline.py", + "sha256": "7eb36d7eb66a507c5a6e9e50626abed9f65f6bb44f1747aadf4511b9756d5aec" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-negative-browser.cjs", + "sha256": "d367ba3b99d6b503bd9156c5d03a346227f9b35c762dd956bb670ff322624ab9" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-negative-verify.py", + "sha256": "de960234984d7f6c1101477129492b4c908b7de3c57a5b3679cf3b71482dce60" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-unknown-seed.py", + "sha256": "8d9fa4796af8095960d140491108d9a1b081f3af01c474c46006376bd504af63" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-unknown-browser.cjs", + "sha256": "d11fefc264bfd19a44fd6306116347655a2a97c64170057261955d9896a838f2" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-unknown-build.cjs", + "sha256": "12e80c561944b85ea91cabb854af3cb54c8a15a620d46a08a3778e03301228ff" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\baseline-evidence.json", + "sha256": "fb14189188916f80720e0236b8b72df97f2d204ba28d4b0e016c8427ce060c78" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\seed-evidence.json", + "sha256": "869689bbefa26acf09c3b2e09a915788bccd8f07c8f2a64328e3ccab796bdce8" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\browser-evidence.json", + "sha256": "392ed202717be8e8207d07739bedc4a2fda7aefa97694cd7f1b7115228fbcc05" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\domain-evidence.json", + "sha256": "26fafed76ceb3af3a075344b6fd7bd80160592220b227ecab57b751e0094f8ad" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\run-state.json", + "sha256": "ebd871d6d44f64af1eb7ce4df205c2d52480611d118f9fbd79c0bf33387575fe" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\attempt-1792905580688.json", + "sha256": "9f8de2161bf29c761c5ac62ef378cdfaefbffb43c0474470bf1909d6d25d74db" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-negative\\attempt-1792905674286.json", + "sha256": "cad17b0928c38f42e1c44cdc264ff81c07d27dbf411b06c3e7a7ee76d257f6b1" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-unknown-submit\\baseline-evidence.json", + "sha256": "51b945f2660febe09614fcb2acf2ef2981f80c98c1c83d716b1b682fc557edd6" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-unknown-submit\\seed-evidence.json", + "sha256": "03ef0063e641d5dd04ad57c0df5dc93a7a70d15c19cfb92e61cae423ebac9d4f" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-unknown-submit\\browser-evidence.json", + "sha256": "14a60232c053dd4a7302d58d4625508fef78261067cf719b9b93dbc879838148" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-unknown-submit\\domain-evidence.json", + "sha256": "1e45bb48c23cb3b243dccf177dd8e32d7d00f75e478b4d63ba5316e5df4106f8" + }, + { + "group": "NEG", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-unknown-submit\\run-state.json", + "sha256": "519fc280cea53165678098763478f7ae04cd9a07979210bccd9ff1c5e596a3d2" + }, + { + "group": "NEG", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-unknown-angular\\qa-build-provenance.json", + "sha256": "629c89f6c94bc6fa3375f451c6bf9728a3f1d4948da547cc84c26a2d8d705ace" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-seed.py", + "sha256": "dc874134d571e7f64fe1c0b6cd2e1d0f87c4fc7010548d3a88d846c03fc1cd08" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-recover-seed.py", + "sha256": "7f5a2ebf83da47ee731b47b6ed1af7b3cb7f3a88d928f396efbf9eb5210d9e2d" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-control.py", + "sha256": "5a9b1ec2a7326facbda890a58096891ef1194d969c03c8a25a1ac6a096378844" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-browser.cjs", + "sha256": "0e64d146ca6bef47cf8d261dec875a61201e736f34a57ab906b4d8073e1c469a" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-build.cjs", + "sha256": "acd43db9de6a2d8c34c862ed4b3ac3960eea95718b81de3161eff00b7a74df2f" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-spa.py", + "sha256": "8e846d57f360bfa5157f117f0c2cdc90a56fb19e83f6bc7e3338fa0110ed3e38" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-copy.cjs", + "sha256": "27b8048873717c0abb1c7981556f9dbac820d04122027f5dff66f08ef8f03411" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-verify.py", + "sha256": "a66fa670ad19c8687aaaef4b853fb92b1d3bb7a46f0b33753276d3d3a3777871" + }, + { + "group": "FORM", + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-form-qa-package.py", + "sha256": "f580bc8bed6e8a79bf5d783cffab1be2bb7ea9c7c232bd0fe699515e2963e731" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-attempt-1792918390582.json", + "sha256": "274cfba317ad631b46511fad1fa5126ca0b405694aeec543612354c849a1b608" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-attempt-1792918748942.json", + "sha256": "41ba812fc31694b1f8bf9837f20387d2c005085eeb1992e0a107beabcba77b89" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-attempt-1792918843306.json", + "sha256": "082d72cb73543bd0eba278d7fdc31f19badce0989ba255f2c2213e529cd66bc0" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-failure-1792918390585.png", + "sha256": "ecc11995e06f86690d25d228f5d822127f6903c85f0369445363d09c6e197302" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-failure-1792918750574.png", + "sha256": "1277dd318bf17b076364376af8212617f9a19282fe035b30496ab8dfd64cb2d2" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-failure-1792918843311.png", + "sha256": "501fc73e9d6315217a8c63cd3482c4dafed3b4099dd9808c20574c234488db62" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\browser-state.json", + "sha256": "d61577f16c0beff382398db18106877bc8892db07878bc7e4fbdf4fa0cc47c5c" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\build-after.json", + "sha256": "bffd1fb0fd6d88491b5fb8777bc3d63bfb04fc9a164c45070ef2a78385e4134c" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\build-final.json", + "sha256": "e12985c2b8518b19fa1ba2308061ad35341cfd21da5883cf44a049ad78da89d3" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\case_championship-draft-zero-false.png", + "sha256": "6870e61a45945be75f03dedf9694ec8d43ab95974bb1546933a47bf7517ac47a" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\case_championship-manager-configured.png", + "sha256": "924ba1ee038b066181dc6a4f9134cd16377c5f1ca4926b1c934e897a65a90401" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\domain-and-contract-evidence.json", + "sha256": "a20383a09ae4abffa72ab48653f34b84997fbc20d76a20b50d5687cd1ce78a22" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\hackathon-draft-zero-false.png", + "sha256": "7269f5eb603c89b232a9a0b6f571e9cb2048c1642c7ecf5cdd429a8c6e377f3f" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\hackathon-manager-configured.png", + "sha256": "9723e132d752c35c318da7100177e5c30c1324a2a3fac2ad9908c8608772d292" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\noncompetitive-copy-final.json", + "sha256": "667e1d1227b4c32baa4d72adc82201eab187f286c7232336d90e6aa92c4cc452" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\noncompetitive-copy-final.png", + "sha256": "b233dc71891356b81c60ae4148b58bd80a6d1b16fb65aa6492eba80822bba6be" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\noncompetitive-draft-zero-false.png", + "sha256": "bf5d9c8292f9456b55b315297c4f1becc3cd67e25d1dde11f358dcf602d0005f" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\noncompetitive-manager-configured.png", + "sha256": "924ba1ee038b066181dc6a4f9134cd16377c5f1ca4926b1c934e897a65a90401" + }, + { + "group": "FORM", + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-form-settings\\setup-recovery.json", + "sha256": "910b28be662c6e93b181ece9e11f3ef05cee868858e86c3a034166241adcab9f" + } + ], + "formCuration": "FORM committed summaries intentionally remove raw event forms and add a separately verified final read; only raw hashes are compared for FORM, not semantic equality of curated reports.", + "newRawArtifacts": [ + { + "file": "case_championship-answers.xlsx", + "sha256": "45dd742d6a66d89908fd001140cb7ab9583f076a2bffd7f5408becb6da907eca", + "bytes": 8150 + }, + { + "file": "hackathon-answers.xlsx", + "sha256": "c3d79ee5d3d96f1e0faa9e210c4d289762eef26d852265fb0e97e1eaef6674b6", + "bytes": 8347 + }, + { + "file": "http-evidence.json", + "sha256": "b3bcccad190a9768b298a7d5b868fed8ac1b2b51f8613939c5f8ca5e6b029143", + "bytes": 5597 + }, + { + "file": "http-wire.json", + "sha256": "539b9c285a0818365a701a4777640179a12cd1550aca954e335967c096720ff2", + "bytes": 50809 + }, + { + "file": "noncompetitive-answers.xlsx", + "sha256": "4e6c53e137af9765ca6a7b48c6ae84b21e8ddbc20b0b7715267dd10108d81aae", + "bytes": 8148 + } + ], + "helpers": [ + { + "file": "procollab-draft-accept-http.py", + "sha256": "ef23d8e9b08a457b5663a4d8cd290be632be04895d6910a9d0b7e9479dbb1009" + }, + { + "file": "procollab-draft-accept-races.py", + "sha256": "2ce5799fe72d6cac0f6e63e20cf7f610c723bf2dd50a9b088437182a4fb28462" + }, + { + "file": "procollab-draft-accept-front-source.cjs", + "sha256": "3a937703e8b796d27ddd763b6ba8b04086114e93d3cbcbcd3168cd0220aaa285" + } + ], + "scope": "New readonly HTTP/XLSX/snapshot verification and six existing focused PostgreSQL race tests; historical browser phases are not replayed.", + "domainMutations": 0, + "newFixtures": 0 +} diff --git a/docs/qa/program-solutions-draft-acceptance-20261025/http-evidence.json b/docs/qa/program-solutions-draft-acceptance-20261025/http-evidence.json new file mode 100644 index 00000000..37eb1349 --- /dev/null +++ b/docs/qa/program-solutions-draft-acceptance-20261025/http-evidence.json @@ -0,0 +1,174 @@ +{ + "status": "PASS", + "backendHead": "db7ffe4d6124b16232685fb49f77105f1436bacf", + "c00": "1.16.0", + "jsonResponses": 7, + "xlsxResponses": 3, + "totalResponses": 10, + "checks": [ + { + "program": 27, + "solution": 42, + "key": "case_championship", + "jsonNumberType": "int", + "jsonNumberValue": 0, + "jsonBooleanValue": false, + "xlsxNumberType": "n", + "xlsxBooleanType": "b", + "missingOptionalField": 209, + "missingAnswerRowAbsent": true, + "definitionRowPresent": true + }, + { + "program": 28, + "solution": 43, + "key": "hackathon", + "jsonNumberType": "int", + "jsonNumberValue": 0, + "jsonBooleanValue": false, + "xlsxNumberType": "n", + "xlsxBooleanType": "b", + "missingOptionalField": 221, + "missingAnswerRowAbsent": true, + "definitionRowPresent": true + }, + { + "program": 29, + "solution": 44, + "key": "noncompetitive", + "jsonNumberType": "int", + "jsonNumberValue": 0, + "jsonBooleanValue": false, + "xlsxNumberType": "n", + "xlsxBooleanType": "b", + "missingOptionalField": 229, + "missingAnswerRowAbsent": true, + "definitionRowPresent": true + }, + { + "program": 11, + "solution": 12, + "template": "case_championship", + "snapshotFormAnswersMembersUnchanged": true, + "liveFormDifferent": true, + "newRequiredOnlyInLive": [ + 82 + ], + "submittedReadinessUnaffected": true + }, + { + "program": 12, + "solution": 13, + "template": "hackathon", + "snapshotFormAnswersMembersUnchanged": true, + "liveFormDifferent": true, + "newRequiredOnlyInLive": [ + 83 + ], + "submittedReadinessUnaffected": true + } + ], + "events": [ + { + "role": "form:captain", + "path": "/programs/27/solutions/42/", + "status": 200, + "responseSha256": "d7bcbd548ee07c33fa275c5461962c03a05ae85d8598f744cdc7c984552bea5d", + "cacheControl": null + }, + { + "role": "form:manager", + "path": "/programs/27/management/solution-analytics/export/?kind=solutions", + "status": 200, + "binary": "case_championship-answers.xlsx", + "bytes": 8150, + "sha256": "45dd742d6a66d89908fd001140cb7ab9583f076a2bffd7f5408becb6da907eca", + "cacheControl": "private, no-store" + }, + { + "role": "form:captain", + "path": "/programs/28/solutions/43/", + "status": 200, + "responseSha256": "b8d9e37a6a29461e121338a343da5ce4a50f02c50a91b8dd12bdfca8d913c012", + "cacheControl": null + }, + { + "role": "form:manager", + "path": "/programs/28/management/solution-analytics/export/?kind=solutions", + "status": 200, + "binary": "hackathon-answers.xlsx", + "bytes": 8347, + "sha256": "c3d79ee5d3d96f1e0faa9e210c4d289762eef26d852265fb0e97e1eaef6674b6", + "cacheControl": "private, no-store" + }, + { + "role": "form:captain", + "path": "/programs/29/solutions/44/", + "status": 200, + "responseSha256": "6dcbd5572cf4c835b7ae3b463f1af52e6cd4f3eb718b13febf9b203ed8a0473a", + "cacheControl": null + }, + { + "role": "form:manager", + "path": "/programs/29/management/solution-analytics/export/?kind=solutions", + "status": 200, + "binary": "noncompetitive-answers.xlsx", + "bytes": 8148, + "sha256": "4e6c53e137af9765ca6a7b48c6ae84b21e8ddbc20b0b7715267dd10108d81aae", + "cacheControl": "private, no-store" + }, + { + "role": "negative:captain", + "path": "/programs/11/solutions/12/", + "status": 200, + "responseSha256": "b1ccd56edfeb73130c1f36a5fbbd6456a3291354f761aee5d43d03166e845134", + "cacheControl": null + }, + { + "role": "negative:captain", + "path": "/programs/11/solution-form/", + "status": 200, + "responseSha256": "d37672fd3374fc8ee35443af15d3628400277cac21088b0c7fc247de0a8be507", + "cacheControl": null + }, + { + "role": "negative:captain", + "path": "/programs/12/solutions/13/", + "status": 200, + "responseSha256": "b6497232b8d04a69f6123d6a4783109cdf48f99f4ca0e175ff7e9f4590592b17", + "cacheControl": null + }, + { + "role": "negative:captain", + "path": "/programs/12/solution-form/", + "status": 200, + "responseSha256": "5879b4be837e6719627d7c29f1e7eb8ec8da057873848d3317afa4d326dd1599", + "cacheControl": null + } + ], + "domainModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "domainBefore": "f15e4e8a179c7a4e3e046a89d4b244973da0aa7f024c789b4b0964eb41423d9b", + "domainAfter": "f15e4e8a179c7a4e3e046a89d4b244973da0aa7f024c789b4b0964eb41423d9b", + "domainMutations": 0, + "newFixtures": 0, + "limits": [ + "Read-only current data/export verification; not a replay of historical browser mutation phases.", + "Missing optional answer has no answer row; its definition stays in the workbook." + ] +} diff --git a/docs/qa/program-solutions-legacy-acceptance-20261025/README.md b/docs/qa/program-solutions-legacy-acceptance-20261025/README.md new file mode 100644 index 00000000..454e563f --- /dev/null +++ b/docs/qa/program-solutions-legacy-acceptance-20261025/README.md @@ -0,0 +1,40 @@ +# QA01: несколько программ и прежние заявки-проекты + +25.10.2026. AC20 и AC22 подтверждены в указанном scope. Общий QA01/REL01 остается открытым; административные исключения после сдачи согласованы пользователем и требуют отдельной реализации. [Матрица](../../product/program-solutions-qa01.md), [evidence](evidence.json), [HTTP](http-evidence.json), [фазы браузера](browser-summary.json). + +## Проверенное поведение + +| AC | Доказательство | +| --- | --- | +| AC20 | Captain9 работает с решениями45/47 программ30/31: разные определения, точные ответы с пробелами/pipe, int0/boolean false, свои приглашения51/52. Чужая пара program/solution дает404 для GET/PATCH/submit и ничего не меняет. Expert15 получает только свои оценки решений34/36 программ23/24 и правильные criteria IDs. Captain не получает expert права. Тот же user15 является manager23/24, но только expert34: analytics23/24 разрешена, analytics30/34 запрещена403; legacy expert list34 доступен. Контекст captain34 остается legacy_project4/link3. Совпадающие Project3/4 и Solution3/4 не смешиваются; недоступное решение дает404, правильный Project читается | +| AC22 | Два свежих Project созданы через прежний POST/PATCH: обычный3 и конкурсный4 в legacy программе34. Через действующий Angular editor сохранено название, приглашен участник, получатель принял приглашение; другое приглашение изменено и отклонено, третье отозвано. Состав каждого — captain9/member10, ожидающих нет. Обычный Project сохранен с draft=False без program link. Конкурсные поля255/256 сохранены, отмена подтверждения не сдает заявку, единственный submit POST link3 дает200 и дату сдачи. Project4 остается закрытым; ProgramSolution не создается | + +Runtime backend: db7ffe4d6124b16232685fb49f77105f1436bacf; прежний local HEAD04637f6 добавлял только DRAFT acceptance документы. Frontend до исправления: f6f1ba96f369d06d502b250b98f3fbcf0963309c; после: d0b7ba6ea2087f979ca51fb0f6cb03e28d3c7845. C001.16.0 не менялся, три общих contract files byte-identical. + +39 новых проверочных HTTP responses:23 multi-program,7 dual-role и9 final legacy reads;27 новых solution responses и2 корректных request bodies проверены по C00. Legacy DTO не выдаются за часть нового solution OpenAPI. Два cross-program commands отклонены404, domain mutations verifier=0. + +В браузерном журнале340 responses, включая318 GET и22 команды; отдельно4 setup POST/PATCH. Эти числа включают повторные чтения при продолжении сценария. Реальное создание legacy fixtures намеренно добавило2 Project,4 Collaborator и связанные данные новой программы; утверждение «создание решения не создает Project» относится к ранее проверенному FORM пути, а не к этому старому POST /projects/. + +Все ранее существовавшие строки22 выбранных доменных моделей до setup и после browser/final verification совпали по canonical digest. В выборку входят Project/Collaborator/Invite, программы/поля/регистрации/связи, все ProgramSolution/Answer/Member/Invitation, оценки/назначения обоих форматов, Expert и M2M, Industry/UserFile. Новых ProgramSolution=0. Auth/User.last_activity, cache и чтение свежих Project не входят в этот инвариант. Реальных программ, dev/production deploy, merge или переноса данных не было. + +## Найденное исправление редактора + +При возврате с «Команды» на «Данные для конкурсов» активная вкладка оставалась без содержимого; прямой вход в additional показывал поля. Отдельная read-only probe воспроизвела initialSelectCount=1, afterNavigationSelectCount=0 без pageerror/local request failure. Выбранный additional блок теперь загружается `@defer (on immediate)`; остальные вкладки и API не менялись. + +Новый parent integration test запрещает idle callback и проверяет появление выбранной панели. Дочерний renderer в этом тесте заменен stub; настоящие поля/значения/сдача проверены в браузере. До изменения trigger — один assert FAIL, после —4/4 PASS. Первая версия test harness не определяла cancelIdleCallback; это исправлено до содержательного red run. Финальный полный frontend:2844 tests/480 files, strict C00 types, development build, scoped ESLint и locked Prettier3.8.3/2017 files PASS. Исходный HTML runtime совпадает с commit после нормализации CRLF; spec после build форматировался и затем прошел полный suite. + +Native PostgreSQL legacy regression:148 tests/OK, отдельная ICU ru-RU test DB. Прочитаны assertions создания/привязки Project, доступа к detail, team permissions/freeze/min/max, переходов Invite и старой сдачи/кейсов. Это focused suite, не148 дополнительных уникальных tests сверх полного CI. Полный CI исходного backend db7ffe4: PG39 locking/vacancy +1717 full tests/OK, General1717/OK(skipped59). [PG](https://github.com/PROCOLLAB-github/api/actions/runs/40186181126), [General](https://github.com/PROCOLLAB-github/api/actions/runs/40186177144). Новые docs commits не объявляются проверенными этими же CI refs. + +## Происхождение и ограничения + +Сохранены11 browser invocations и завершенные фазы; это resumed acceptance, а не один непрерывный PASS. Обычный Project завершен в attempt4, legacy team —5, поля —9, отмена/подтверждение/submit —11. Последний проход без новых JavaScript errors; историческая dynamic-import ошибка остается в отчете. + +- Setup1 откатился целиком из-за отсутствующего show_filter. Setup2 создал программу34 и оба Project через4 HTTP writes, затем metadata assertion ошибочно ожидал partner_program.id вместо program_id. Recovery завершил только private manifest, ничего не пересоздал и не изменил в доменных данных. +- Harness уточнен по реальным контролам: исчезающее cookie notice, native name input, searchbox/combobox, обновление получателем списка уведомлений, нормализация текста nav, возврат в editor после штатного save-draft redirect, legacy modal без role=dialog. Завершенные приглашения/сдача не сбрасывались. +- В attempt7 зарегистрирован localhost ERR_CONNECTION_REFUSED при параллельной загрузке chunks. Увеличена очередь только вспомогательного static server. В attempt6 dynamic import error при ранее наблюденном HTTP200 сохраняется как ошибка с неустановленной причиной; она не объявляется тем же доказанным отказом соединения. +- Initial final verifier неверно ожидал принятые/отклоненные записи в pending-only /invites/ list. Исправлен по действующему queryset: list пуст, history читается через detail. Это read-only ошибка verifier; данные не менялись. +- Дополнительная post-submit probe editor была перенаправлена на detail действующим guard и не считается повторной проверкой редактирования. Финальный detail readback и DB подтверждают прежний submitted путь. + +Raw logs/helpers/screenshots/build metadata находятся в `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-legacy`; hashes и исходные Temp paths в evidence. Credentials/JWT остаются вне Git. Origins API/WS заменены только в одном generated chunk копии development build; исходные environment files не менялись. Внешняя сеть блокировалась, поэтому avatars на screenshots отсутствуют. Проверены team и submitted states; полная desktop/a11y/zoom/production CDN приемка не заявляется. + +AC18/19, D02/D03 UI, согласованные административные исключения/retention, конкурсная публикация несданных черновиков и полный REL01 остаются в первом выпуске. AC21/BE10 — отдельный последующий перенос по утвержденному списку. Новое решение пользователя не дает разрешения удалять или автоматически переносить старые Project заявки. diff --git a/docs/qa/program-solutions-legacy-acceptance-20261025/browser-summary.json b/docs/qa/program-solutions-legacy-acceptance-20261025/browser-summary.json new file mode 100644 index 00000000..fbff42c4 --- /dev/null +++ b/docs/qa/program-solutions-legacy-acceptance-20261025/browser-summary.json @@ -0,0 +1,347 @@ +{ + "status": "PASS", + "attempts": 11, + "projects": { + "3": { + "kind": "ordinary", + "nameSaved": true, + "memberInvite": 1, + "memberAccepted": true, + "declineInvite": 2, + "declined": true, + "revokeInvite": 3, + "revoked": true, + "published": true, + "complete": true + }, + "4": { + "kind": "legacy", + "nameSaved": true, + "memberInvite": 4, + "memberAccepted": true, + "declineInvite": 5, + "declined": true, + "revokeInvite": 6, + "revoked": true, + "fieldsSaved": true, + "confirmationCancelled": true, + "submitted": true, + "complete": true + } + }, + "checks": [ + { + "project": 3, + "kind": "ordinary", + "check": "actual edit/save/team/invite/accept/role edit/decline/revoke/publish", + "member": 10 + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 4, + "kind": "legacy", + "check": "actual edit/save/team/invite/accept/role edit/decline/revoke/cancel-confirm-submit", + "member": 10 + } + ], + "responses": 340, + "commands": 22, + "methodCounts": { + "GET": 318, + "POST": 11, + "PATCH": 2, + "PUT": 7, + "DELETE": 2 + }, + "javascriptErrorsThisAttempt": [], + "historicalJavascriptErrors": [ + { + "role": "captain", + "message": "Failed to fetch dynamically imported module: http://127.0.0.1:8886/chunk-N4OPMJMV.js" + } + ], + "commandLog": [ + { + "role": "captain", + "method": "PUT", + "path": "/projects/3/", + "status": 200, + "request": { + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 10, + "project": 3, + "role": "Аналитик совместимости" + } + }, + { + "role": "member", + "method": "POST", + "path": "/invites/1/accept/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 11, + "project": 3, + "role": "Черновая роль" + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/invites/2/", + "status": 200, + "request": { + "role": "Проверенная роль", + "specialization": "" + } + }, + { + "role": "pending_a", + "method": "POST", + "path": "/invites/2/decline/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 12, + "project": 3, + "role": "Проверка отзыва" + } + }, + { + "role": "captain", + "method": "DELETE", + "path": "/invites/3/", + "status": 204, + "request": null + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/3/", + "status": 200, + "request": { + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": false + } + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "" + }, + { + "field_id": 256, + "value_text": "" + } + ] + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/4/", + "status": 200, + "request": { + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 10, + "project": 4, + "role": "Аналитик совместимости", + "program_link_id": 3 + } + }, + { + "role": "member", + "method": "POST", + "path": "/invites/4/accept/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 11, + "project": 4, + "role": "Черновая роль", + "program_link_id": 3 + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/invites/5/", + "status": 200, + "request": { + "role": "Проверенная роль", + "specialization": "" + } + }, + { + "role": "pending_a", + "method": "POST", + "path": "/invites/5/decline/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 12, + "project": 4, + "role": "Проверка отзыва", + "program_link_id": 3 + } + }, + { + "role": "captain", + "method": "DELETE", + "path": "/invites/6/", + "status": 204, + "request": null + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "Город" + }, + { + "field_id": 256, + "value_text": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/4/", + "status": 200, + "request": { + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "Город" + }, + { + "field_id": 256, + "value_text": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/partner-program-projects/3/submit/", + "status": 200, + "request": {} + } + ], + "curation": "Complete command requests/statuses retained; nested legacy response bodies remain in raw browser-evidence.json with SHA256. Diagnostic resources/auth/welcome activity are outside this response counter." +} diff --git a/docs/qa/program-solutions-legacy-acceptance-20261025/evidence.json b/docs/qa/program-solutions-legacy-acceptance-20261025/evidence.json new file mode 100644 index 00000000..7af2eca6 --- /dev/null +++ b/docs/qa/program-solutions-legacy-acceptance-20261025/evidence.json @@ -0,0 +1,706 @@ +{ + "status": "PASS", + "acceptance": [ + "AC20", + "AC22" + ], + "fullQaComplete": false, + "backendRuntime": "db7ffe4d6124b16232685fb49f77105f1436bacf", + "backendBeforeDocs": "04637f6b87ca8790893bffe58b80d464f002adee", + "frontendBefore": "f6f1ba96f369d06d502b250b98f3fbcf0963309c", + "frontendFix": "d0b7ba6ea2087f979ca51fb0f6cb03e28d3c7845", + "c00": { + "version": "1.16.0", + "schemas": 128, + "operations": 49, + "scenarios": 217, + "sharedFilesByteIdentical": { + "openapi.json": "7f242056497fe6c0792c299f688573213d2b9c491710b5d3f9812f994d994c6a", + "examples.json": "94917c80ec36279bf654258887e97c288ee41603de8592db7405991b98c4c22f", + "README.md": "0ba64f08670737c52865bd3667cc0a914ff07722b366faac7c465020abd54306" + } + }, + "http": { + "responses": 39, + "c00ValidatedResponses": 27, + "validatedValidBodies": 2, + "newSolutionDomainWrites": 0 + }, + "browser": { + "status": "PASS", + "attempts": 11, + "projects": { + "3": { + "kind": "ordinary", + "nameSaved": true, + "memberInvite": 1, + "memberAccepted": true, + "declineInvite": 2, + "declined": true, + "revokeInvite": 3, + "revoked": true, + "published": true, + "complete": true + }, + "4": { + "kind": "legacy", + "nameSaved": true, + "memberInvite": 4, + "memberAccepted": true, + "declineInvite": 5, + "declined": true, + "revokeInvite": 6, + "revoked": true, + "fieldsSaved": true, + "confirmationCancelled": true, + "submitted": true, + "complete": true + } + }, + "checks": [ + { + "project": 3, + "kind": "ordinary", + "check": "actual edit/save/team/invite/accept/role edit/decline/revoke/publish", + "member": 10 + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 3, + "check": "resumed completed project read only" + }, + { + "project": 4, + "kind": "legacy", + "check": "actual edit/save/team/invite/accept/role edit/decline/revoke/cancel-confirm-submit", + "member": 10 + } + ], + "responses": 340, + "commands": 22, + "methodCounts": { + "GET": 318, + "POST": 11, + "PATCH": 2, + "PUT": 7, + "DELETE": 2 + }, + "javascriptErrorsThisAttempt": [], + "historicalJavascriptErrors": [ + { + "role": "captain", + "message": "Failed to fetch dynamically imported module: http://127.0.0.1:8886/chunk-N4OPMJMV.js" + } + ], + "commandLog": [ + { + "role": "captain", + "method": "PUT", + "path": "/projects/3/", + "status": 200, + "request": { + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 10, + "project": 3, + "role": "Аналитик совместимости" + } + }, + { + "role": "member", + "method": "POST", + "path": "/invites/1/accept/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 11, + "project": 3, + "role": "Черновая роль" + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/invites/2/", + "status": 200, + "request": { + "role": "Проверенная роль", + "specialization": "" + } + }, + { + "role": "pending_a", + "method": "POST", + "path": "/invites/2/decline/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 12, + "project": 3, + "role": "Проверка отзыва" + } + }, + { + "role": "captain", + "method": "DELETE", + "path": "/invites/3/", + "status": 204, + "request": null + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/3/", + "status": 200, + "request": { + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": false + } + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "" + }, + { + "field_id": 256, + "value_text": "" + } + ] + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/4/", + "status": 200, + "request": { + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 10, + "project": 4, + "role": "Аналитик совместимости", + "program_link_id": 3 + } + }, + { + "role": "member", + "method": "POST", + "path": "/invites/4/accept/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 11, + "project": 4, + "role": "Черновая роль", + "program_link_id": 3 + } + }, + { + "role": "captain", + "method": "PATCH", + "path": "/invites/5/", + "status": 200, + "request": { + "role": "Проверенная роль", + "specialization": "" + } + }, + { + "role": "pending_a", + "method": "POST", + "path": "/invites/5/decline/", + "status": 200, + "request": {} + }, + { + "role": "captain", + "method": "POST", + "path": "/invites/", + "status": 201, + "request": { + "user": 12, + "project": 4, + "role": "Проверка отзыва", + "program_link_id": 3 + } + }, + { + "role": "captain", + "method": "DELETE", + "path": "/invites/6/", + "status": 204, + "request": null + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "Город" + }, + { + "field_id": 256, + "value_text": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + }, + { + "role": "captain", + "method": "PUT", + "path": "/projects/4/", + "status": 200, + "request": { + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "region": "Москва", + "links": [], + "industry_id": 1, + "description": "Проверка прежнего сценария проекта", + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "actuality": "Проверяем совместимость", + "target_audience": "Участники программ", + "problem": "Сохраняем существующий сценарий", + "achievements": [], + "draft": true + } + }, + { + "role": "captain", + "method": "PUT", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "request": [ + { + "field_id": 255, + "value_text": "Город" + }, + { + "field_id": 256, + "value_text": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/partner-program-projects/3/submit/", + "status": 200, + "request": {} + } + ], + "curation": "Complete command requests/statuses retained; nested legacy response bodies remain in raw browser-evidence.json with SHA256. Diagnostic resources/auth/welcome activity are outside this response counter." + }, + "final": { + "status": "PASS", + "prefix": "qa01l-20261025-c6c0e219", + "checks": [ + { + "project": 3, + "kind": "ordinary", + "nameSaved": true, + "accepted": 1, + "declined": 2, + "revoked": 3, + "members": 2, + "submitted": false, + "projectNamespacePreserved": true + }, + { + "project": 4, + "kind": "legacy", + "nameSaved": true, + "accepted": 4, + "declined": 5, + "revoked": 6, + "members": 2, + "submitted": true, + "projectNamespacePreserved": true + } + ], + "responses": 9, + "old22ModelsUnchanged": true, + "protectedBaselineHash": "e508ed645c913db00de6d7318605a03cc7884f9a183ed7a6fb8b15da306b6b1b", + "newSolutions": 0, + "intentionalNewProjects": 2, + "domainVerifierWrites": 0, + "notes": "View/auth activity on fresh fixtures may be updated by ordinary GET/login; all selected preexisting domain rows remain byte-equal by canonical digest." + }, + "multiProgram": { + "status": "PASS", + "c00": "1.16.0", + "checks": [ + { + "program": 30, + "solution": 45, + "kind": "solution", + "captain": 9, + "invitation": 51, + "answersAndPermissionsScoped": true + }, + { + "program": 31, + "solution": 47, + "kind": "solution", + "captain": 9, + "invitation": 52, + "answersAndPermissionsScoped": true + }, + { + "program": 34, + "project": 4, + "programLink": 3, + "kind": "legacy_project", + "captain": 9, + "currentApplicationCorrect": true + }, + { + "project": 3, + "solutionWithSameId": 3, + "solutionProgram": 4, + "sameIdSolutionStatus": 404, + "projectNamespaceCorrect": true + }, + { + "project": 4, + "solutionWithSameId": 4, + "solutionProgram": 6, + "sameIdSolutionStatus": 404, + "projectNamespaceCorrect": true + }, + { + "program": 23, + "solution": 34, + "expert": 15, + "criterionIds": [ + 39, + 40, + 45, + 46 + ], + "ownScoreCount": 4, + "ownScoresScoped": true, + "captainCannotAcquireExpertRole": true + }, + { + "program": 24, + "solution": 36, + "expert": 15, + "criterionIds": [ + 42, + 43, + 47, + 48 + ], + "ownScoreCount": 4, + "ownScoresScoped": true, + "captainCannotAcquireExpertRole": true + } + ], + "responses": 23, + "validation": { + "responses": 21, + "validRequests": 2 + }, + "protectedModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.PartnerProgramProject", + "partner_programs.PartnerProgramFieldValue", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "projects.Collaborator", + "invites.Invite", + "project_rates.ProjectScore", + "project_rates.ProjectExpertAssignment", + "industries.Industry", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "protectedBefore": "e508ed645c913db00de6d7318605a03cc7884f9a183ed7a6fb8b15da306b6b1b", + "protectedAfter": "e508ed645c913db00de6d7318605a03cc7884f9a183ed7a6fb8b15da306b6b1b", + "protectedRowsUnchanged": true, + "verifierDomainMutations": 0, + "newSolutions": 0, + "scope": "Read-only and rejected wrong-program commands over existing solution fixtures plus the new legacy context; browser separately mutates only fresh projects/program34." + }, + "dualRole": { + "status": "PASS", + "responses": 7, + "validation": { + "responses": 6, + "validRequests": 0 + }, + "checks": [ + { + "actor": 15, + "program": 23, + "role": "manager", + "status": 200, + "programScoped": true + }, + { + "actor": 15, + "program": 24, + "role": "manager", + "status": 200, + "programScoped": true + }, + { + "actor": 15, + "program": 34, + "role": "expert", + "legacyEvaluationStatus": 200, + "managerStatus": 403, + "currentApplication": null + } + ], + "old22ModelsUnchanged": true, + "domainWrites": 0 + }, + "tests": { + "legacyPostgres": 148, + "frontend": 2844, + "frontendFiles": 480, + "newParentTest": 1, + "strictTypes": true, + "developmentBuild": true, + "scopedEslint": true, + "lockedFormat": { + "version": "3.8.3", + "sourceFilesBeforeReport": 2017, + "failures": [] + } + }, + "ci": { + "backendProductHead": "db7ffe4d6124b16232685fb49f77105f1436bacf", + "postgres": { + "run": 40186181126, + "locking": 39, + "full": 1717, + "skipped": 0, + "status": "success" + }, + "general": { + "run": 40186177144, + "full": 1717, + "skipped": 59, + "status": "success" + }, + "notProofForLaterDocsCommits": true + }, + "initialBuild": { + "frontend": "f6f1ba96f369d06d502b250b98f3fbcf0963309c", + "sourceBuild": "Current role-contract development build; no subsequent product source changes", + "target": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-angular", + "changes": [ + { + "name": "chunk-HYHMJF3H.js", + "before": "4f800d7385e186870ceaa53d2c933b989bbc4ea2b9cab02c69d94ba8bb221eb8", + "after": "5b11f6d62c68f4bfb450ce7e1e054996865dafa61bd84756b141c067977919f2" + } + ] + }, + "fixedBuild": { + "baseHead": "f6f1ba96f369d06d502b250b98f3fbcf0963309c", + "sourceBuild": "Development build with selected additional step deferred on immediate; two uncommitted source files", + "target": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-immediate-angular", + "sourceHashes": { + "projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html": "e537f45cddea44a1b5b6a24888e09f3ef5cfb2279d665c883ef9171190c30b8f", + "projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts": "027e42b9b6cc0887b86e1535caf8c65b4fe50a16c513d177b21acac04fb6d9f9" + }, + "changes": [ + { + "name": "chunk-HCU4IA5Y.js", + "before": "093f0640ee59c7795f20bc822d5261b88825d79ce68465c938f9ebb93cf141d5", + "after": "dcbd637c62428b2061d98c02a492563190035268551d561d39d557d393133ef8" + } + ] + }, + "rawRoot": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-legacy", + "rawArtifacts": { + "additional-probe-navigation.json": "e0aa7a477eacf028b2479b23259e26fb26ec1230ffc5778e87463f61e164b72f", + "additional-probe.json": "500008aaad00a5ee00cf0f4139f6a854ecf2443ce6d73d31aaf529c7b7a78f43", + "browser-attempt-1792927488667.json": "a97ca46f0d9eedb77e0c8d2ac245f9869e54fe9a9cb01f06a074a01b0effad02", + "browser-attempt-1792927674419.json": "bf2aeacbb5d94938768f4a29572cd6b882ce45ddb1917dd70bf2b46b7272dfd5", + "browser-attempt-1792927716611.json": "de396579036e17679ff6a26f65e4dfc48ec826fd32e5ce26c809530faac3082a", + "browser-attempt-1792927833067.json": "8263558efcdd6d0e988962c452c4f69b38e22b10f0fb0c65d67a8c6111256291", + "browser-attempt-1792928512169.json": "22535bc2265f32757e73661f8bdf89489e8b3bf2592e77934bea01c2f9ca7f07", + "browser-attempt-1792928584468.json": "043dbb794b2e7d1b460b9e244e3ae6996a6a3cd36983f8659321fda0d9348496", + "browser-attempt-1792928652202.json": "3157a1a79f9560c882f2551f62ec4fa7a9a31d7828717a172a8d5ebfeb8031ea", + "browser-attempt-1792928736607.json": "febfd2bfa4ef72092549e7e2d6c822c7be452fede635262bfafbd05aedd7f8de", + "browser-attempt-1792929263078.json": "8e992ac8ddfcfe77423b456dc9cd98d7ea778eb65a8cae4094098532cc1ce050", + "browser-attempt-1792929451588.json": "cf53fcbb78f8bba6d6635f38292d48211fbb48d83518ccd3f3a64814aee02044", + "browser-error-1792927674424.png": "676a09e1de362e0fdd8aa856312cbf9f5005309cf47aaa4cdab12d16adcdc264", + "browser-error-1792927716617.png": "d7c99a78c767abc72a52e2e025f45585686c9b4f99ecf458ca6ef918cd4f82db", + "browser-error-1792927833071.png": "69187a2adc3a200015331b42b09f8b6bfe3c91745d0341667d9be71255b182f1", + "browser-error-1792928512174.png": "bff7d0e191fdfb85f409fdd2dc127d69c373451b501b0deeb953d6d32dd54ab0", + "browser-error-1792928584475.png": "36178e888824413f4795ccfc3ad206ff0e906da4ad688457aad4fee0b1296d9e", + "browser-error-1792928652210.png": "cb9c3b8047859412e8abcaed51ad9ecefb4bd820c8849af4992e593d500d1836", + "browser-error-1792928736615.png": "57ae02707e4e00d8a90ccf8437e4ed27e0794e3ee92d99496b6bcac755da5335", + "browser-error-1792929263092.png": "a1bc28b62f3afd0e31947ce03e83068f38167ad25791fc22de9947b2686e2be1", + "browser-error-1792929451626.png": "adc9289d2e397bbbe47f1824617aa36cc19193bf480ef9581fd8288197bf65d7", + "browser-evidence.json": "a0dd101be83893d8a55300fbcab855786281d3189c9b95d97198ead049ec08b2", + "browser-state.json": "7f2d24c6e116b7ff7de2e3c98f1855c2f9edfa4e7d957bbf40c10747b730a9db", + "dual-role-evidence.json": "2be2469319f65c9e2ba2d02205fea02e8e2b9cfc756a785a96ee1bc6fe8697d2", + "dual-role-wire.json": "821ca1d48394544c5b0d79e40b7a33cd1f8049a55b08cfa90f6ea0e689073dcd", + "final-evidence.json": "8004139f14ea42f667a43d2ebfdfa57151bc49b389dd1f02656af58bc4f24b6d", + "final-http-wire.json": "bee1b59f5692a1ca468474677fb112b01f616489fe94416177608f21b9e798c5", + "legacy-complete.png": "186979c8f69f5d96c5fb42b470abf03bd54131470e6ccd1294333c3bd6328bf3", + "legacy-detail-complete.png": "dc338ecabe949a0f09433d6d15a66a014b9cb0ba865919453c36e8ceeebc7659", + "legacy-detail-readback.json": "7ff97664622a6ab4c514e0db78f4e3b523447fa4d0386782afcd2ccd8d68f421", + "legacy-team.png": "e6611f2c008c29aceed84d8be5dc0814779e7537531e3a3b6f46041748cf4a3f", + "multi-program-evidence.json": "cb62e1196c2188fa1e2bcb8a3a900246f5b8fdb7c99f31e550c11a096a0d24f2", + "multi-program-wire.json": "34c6ca6fc74933878c68bad72058257a058b4e770270490bea9b6cf8792eee9e", + "ordinary-complete.png": "ae61798cc7e3a71daac9a02744294a5e31edc666a88d0511b8d1efdb9c1b98cc", + "ordinary-team.png": "e6611f2c008c29aceed84d8be5dc0814779e7537531e3a3b6f46041748cf4a3f", + "procollab-dual-role-http.log": "047b156f5a5ea9d3cb4c724593cc1290338f7fa7705e13e7ca2196618b3ec610", + "procollab-legacy-additional-probe-postfix.log": "cbddd5589bd0c826f32a1eb01c1e990ddad4de18f0a6335b5b21e644cb0e1e9e", + "procollab-legacy-browser-attempt10.log": "f9707af48e3f4c3baaca3cb0fbb6aa5f31a8a03f015d4f26a419b7ca5116d67f", + "procollab-legacy-browser-attempt11.log": "7dd6ebb2c2a01715ff243d04b5cee630ef732e5c41e31001433324372426e28f", + "procollab-legacy-browser-attempt2.log": "58178436beeeec783286013eba9c80a6ae2d2376bd7b0787626f466f7977face", + "procollab-legacy-browser-attempt3.log": "4e999b1ab9fdb094dbb1ae17d89681f8521fe1436372499b7d0b1bf7b014892b", + "procollab-legacy-browser-attempt4.log": "0dfce58d2e2ed1f1a2c400a9d1001ad2bb314ba2db95d4e691aef2ab4171c1ad", + "procollab-legacy-browser-attempt5.log": "830b0d89a41497b1460b22695a1cfbb1f1ba12ec83884e2d86062042cbedb2d0", + "procollab-legacy-browser-attempt6.log": "3b02c3850a7fe4dfbc2c09085da13dee8928aaa72d18df6619d8faad045b9571", + "procollab-legacy-browser-attempt7.log": "927b3ee229e9bfa0a72f73fe742cf47dc6c249deb6996b0c98d13bc6bea5da28", + "procollab-legacy-browser-attempt8.log": "1a3238f3df2134e5ed63931cfbfc0d15cf64e0ce1e71ad20883f21a97bbb1b54", + "procollab-legacy-browser-attempt9.log": "dd108945f39867f91aeeb0b12e06d8cc82f1e3208f1b58799ca139e8ad032dc5", + "procollab-legacy-browser.log": "afd790adec2d0e198229ed4045c356262e9815def0ba2b5ff3d90aec88efe75a", + "procollab-legacy-build.json": "1da25202d37cb5863035995aa6013f288b8581979fead80c1aed95bab6a25de3", + "procollab-legacy-defer-before.log": "d881941b54a06ee797c503e6ecabdeb2b4d3ac39d664f30c9d16950783655dfa", + "procollab-legacy-defer-build.log": "2a3374db9fbf0cc7339ab93ff1004880a2512be128198a69b14e6234746b73d5", + "procollab-legacy-defer-green.log": "933d6415f2063cc2f4351fc683774542ffb1184ba113ab29dec39b910e49eda3", + "procollab-legacy-defer-red.log": "349eb123be0082bb3f7c7b97b21c98d196df3ae27a2192be4203d774b0cbe439", + "procollab-legacy-final-corrected.log": "831e42f9abfbdf3d430b51166fc1ccfedaeeb133699260296bf8dc8e09cf357a", + "procollab-legacy-final.log": "77bd594b8af28de54f299a8c0e70d74b4e524996cc1030755309fe56e13d3c00", + "procollab-legacy-format.log": "d7285bc2c3c8e7627b262407c486fd5087d35b5208fad89de12cbf31cd354408", + "procollab-legacy-frontend-suite.log": "320ee2613c9360a3a3090e426376aa83bf1e73df69491a88dbde75cc9d849b27", + "procollab-legacy-immediate-build.json": "1fbcb167745565ea671cbfdacdfc22336599d3b2a238cd023ea4d626ed8146c5", + "procollab-legacy-regression.log": "b7264b0409d95c4e37c0464614c61d8070e3dc32acea376979a1a9b399888c66", + "procollab-legacy-seed-final.log": "920398727ab35f760b48098deda6c9f437e752249171fc81afe674c9bf0e587d", + "procollab-legacy-seed.log": "664983ad4a57ca3d17dc6865a8da00538beed86aea4a8bca427ab3d56fa7ae7d", + "procollab-multi-program-http.log": "31a34fd7492941a4f07cfb150f80b51462e9053e41b52ccab0cf2d4cad7c0e35" + }, + "helpers": { + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-seed-before-idfix.py": "3cad3822be1882403ccc77ada1f57d1d7c73f70a76223c1f3bbff7e59b2602f1", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-seed.py": "3d21ab859e1eae5455a4e683cf7a0604ef8e3d709ffd16b5d213ad586da3c6c4", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-setup-recovery.py": "106b1b9f4265ad7f2dea7d8fdf48e543e6a41fde659694cb436acef0e96058d5", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser.cjs": "9f765447b78104a7e65cf5393ab8b870eb6f30e0546a4975145a0ec9be4b2bd4", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser-before-reload.cjs": "8aca51d4141c119a0c1115f548e3c704f461106a5b870217b86f78eabfdd0760", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser-before-navfix.cjs": "087073213f3aa2c835882a928d3b8e6ca9dd57216610227a0321ddc385c36796", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser-before-attempt-errors.cjs": "5aa22f2cacc033e0c41aca697ea7c9e53091bfd2c4db35595959d22fa5e1350b", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser-before-draft-return.cjs": "c64dc49d6b3376854947e304e631c4a740bf8f83e284313ee919fa939bebd64a", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-browser-before-confirm-locator.cjs": "35e430024597bb5adc5c113275cc219cef7d4270fe853730c8918cec739db2f2", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-multi-program-http.py": "e062da8c685159f13e8551c7a53f2609f1e8addbcb53cfbb1015afb310f59fd3", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-dual-role-http.py": "4c6769390764fca77cdb410269e2b28b2f84a4f8aab8f00456afb54b8982983c", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-final.py": "5c24d4ac94aa80d1564f2c867096db6e613f1375980b98701050446410417485", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-final-before-pending-filter.cjs.py": "ac7ca354a46075acdb50083ab8fe6b9f0eb5b824a86b5091949b97f429375689", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-spa.py": "af79f65408d5b0ff402ab7b11a4e457baf1a8780dc901a4a85f9a78d129e7055", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-spa-before-backlog.py": "47c2bf7e27fc508073ef9b900b1533c4ba2a5bf941f94de5d80d7796ad11713b", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-build.cjs": "2e96026cca7be5abd539c71c80bad9843bcdb7c85e6064149f1a23243331d74e", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-immediate-build.cjs": "cb6455c73735d46c3a83c4c43e80046cb2bc9b234f11dc3f1a03228917999c2f", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-additional-probe-initial.cjs": "1960cde63cd35bf70f531c1d76ba05d6a2bb2815c9ce4fe319cffb1802a0ee8a", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-additional-probe.cjs": "148138eefee350262b77cf21b1e9a7fd2a662fc00783dc1394447fac9ee5c6d7", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-additional-probe-postfix.cjs": "cc79ca7dab9f2347fb3b19ca79096974e0a432c3ce45206b0219d489014fbde2", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-legacy-detail-readback.cjs": "ad7988e0e546ff71850b1c054dae39a58080722af78c52cfeb247625f41883ba" + }, + "limitations": [ + "Resumed phases across11 invocations; historical module error retained", + "Local network guard blocks remote avatars", + "No merge/deploy/real-program switch/automatic migration", + "Admin exceptions approved; implementation not yet extended", + "D02/D03 and competitive draft publication remain pending" + ] +} diff --git a/docs/qa/program-solutions-legacy-acceptance-20261025/http-evidence.json b/docs/qa/program-solutions-legacy-acceptance-20261025/http-evidence.json new file mode 100644 index 00000000..8df2592a --- /dev/null +++ b/docs/qa/program-solutions-legacy-acceptance-20261025/http-evidence.json @@ -0,0 +1,3873 @@ +{ + "multiProgram": [ + { + "actor": "captain", + "method": "GET", + "path": "/programs/30/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 30, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": false, + "submission_deadline": "2026-10-24T09:35:57.603032Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "solution", + "program_id": 30, + "solution_id": 45, + "name": "Сохраненная правка case_championship" + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/30/solutions/45/", + "status": 200, + "request": null, + "response": { + "id": 45, + "program_id": 30, + "version": 5, + "name": "Сохраненная правка case_championship", + "captain_id": 9, + "created_at": "2026-10-25T09:35:57.016759Z", + "updated_at": "2026-10-25T09:35:58.271208Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T09:35:57.020289Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 1, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 30, + "revision": "003d81d4fe4cd4146f701876ee6e9a9c837cf50baa91073330296d7265429e2f", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 230, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука ", + "Медицина" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 231, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 232, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 233, + "type": "url", + "purpose": "video", + "label": "Видео", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 234, + "type": "file", + "purpose": "other", + "label": "Дополнительные материалы", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf", + "zip" + ], + "max_file_size_bytes": 52428800 + } + }, + { + "id": 235, + "type": "number", + "purpose": "other", + "label": "Ноль", + "help_text": "", + "required": false, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 236, + "type": "checkbox", + "purpose": "other", + "label": "Подтверждение", + "help_text": "", + "required": false, + "position": 6, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 230, + "value": " Образование | наука " + }, + { + "field_id": 235, + "value": 0 + }, + { + "field_id": 236, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": false, + "deadline": "2026-10-24T09:35:57.603032Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/30/solutions/45/invitations/?status=pending&limit=100", + "status": 200, + "request": null, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 51, + "version": 1, + "program": { + "id": 30, + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa" + }, + "solution": { + "kind": "solution", + "program_id": 30, + "solution_id": 45, + "name": "Сохраненная правка case_championship" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T09:35:57.583312Z", + "updated_at": "2026-10-25T09:35:57.583312Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + } + ] + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/solution-invitations/51/", + "status": 200, + "request": null, + "response": { + "id": 51, + "version": 1, + "program": { + "id": 30, + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa" + }, + "solution": { + "kind": "solution", + "program_id": 30, + "solution_id": 45, + "name": "Сохраненная правка case_championship" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T09:35:57.583312Z", + "updated_at": "2026-10-25T09:35:57.583312Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/31/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 31, + "storage_mode": "solution", + "is_registered": true, + "is_competitive": true, + "submission_open": false, + "submission_deadline": "2026-10-24T09:35:59.873924Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "solution", + "program_id": 31, + "solution_id": 47, + "name": "Сохраненная правка hackathon" + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/31/solutions/47/", + "status": 200, + "request": null, + "response": { + "id": 47, + "program_id": 31, + "version": 5, + "name": "Сохраненная правка hackathon", + "captain_id": 9, + "created_at": "2026-10-25T09:35:59.228117Z", + "updated_at": "2026-10-25T09:36:00.874616Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T09:35:59.231143Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 1, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 31, + "revision": "2df6ad388a42bdf22e073b802858112e0184765665bbef2ac4ae5c8c77aaf5fe", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 237, + "type": "select", + "purpose": "case", + "label": "Задача или трек", + "help_text": "", + "required": true, + "position": 0, + "options": [ + "Город", + " Образование | наука ", + "Медицина" + ], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 238, + "type": "textarea", + "purpose": "other", + "label": "Краткое описание", + "help_text": "", + "required": false, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": 3000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 239, + "type": "textarea", + "purpose": "other", + "label": "Описание реализации", + "help_text": "", + "required": false, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": 10000, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 240, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": false, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 241, + "type": "textarea", + "purpose": "run_instructions", + "label": "Инструкция запуска", + "help_text": "", + "required": false, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 242, + "type": "url", + "purpose": "demo", + "label": "Демо", + "help_text": "", + "required": false, + "position": 5, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 243, + "type": "file", + "purpose": "presentation", + "label": "Презентация PDF", + "help_text": "", + "required": false, + "position": 6, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 244, + "type": "url", + "purpose": "video", + "label": "Видео", + "help_text": "", + "required": false, + "position": 7, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 245, + "type": "file", + "purpose": "other", + "label": "Дополнительные материалы", + "help_text": "", + "required": false, + "position": 8, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [ + "pdf", + "zip" + ], + "max_file_size_bytes": 52428800 + } + }, + { + "id": 246, + "type": "number", + "purpose": "other", + "label": "Ноль", + "help_text": "", + "required": false, + "position": 9, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 247, + "type": "checkbox", + "purpose": "other", + "label": "Подтверждение", + "help_text": "", + "required": false, + "position": 10, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 237, + "value": " Образование | наука " + }, + { + "field_id": 246, + "value": 0 + }, + { + "field_id": 247, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": false, + "deadline": "2026-10-24T09:35:59.873924Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/31/solutions/47/invitations/?status=pending&limit=100", + "status": 200, + "request": null, + "response": { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 52, + "version": 1, + "program": { + "id": 31, + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa" + }, + "solution": { + "kind": "solution", + "program_id": 31, + "solution_id": 47, + "name": "Сохраненная правка hackathon" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T09:35:59.846614Z", + "updated_at": "2026-10-25T09:35:59.846614Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + } + ] + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/solution-invitations/52/", + "status": 200, + "request": null, + "response": { + "id": 52, + "version": 1, + "program": { + "id": 31, + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa" + }, + "solution": { + "kind": "solution", + "program_id": 31, + "solution_id": 47, + "name": "Сохраненная правка hackathon" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T09:35:59.846614Z", + "updated_at": "2026-10-25T09:35:59.846614Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/30/solutions/47/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/31/solutions/45/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/30/solutions/47/", + "status": 404, + "request": { + "expected_version": 5, + "expected_form_revision": "2df6ad388a42bdf22e073b802858112e0184765665bbef2ac4ae5c8c77aaf5fe", + "name": "Не сохранять между программами" + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/30/solutions/47/submit/", + "status": 404, + "request": { + "expected_version": 5, + "expected_form_revision": "2df6ad388a42bdf22e073b802858112e0184765665bbef2ac4ae5c8c77aaf5fe" + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/34/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 34, + "storage_mode": "legacy_project", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "legacy_project", + "program_id": 34, + "project_id": 4, + "program_link_id": 3, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен" + } + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/projects/3/", + "status": 200, + "request": null, + "response": { + "id": 3, + "team_policy": { + "is_frozen": false, + "program_link_id": null, + "requires_program_context": false, + "program_links": [] + }, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "description": "Проверка прежнего сценария проекта", + "short_description": "Проверка прежнего сценария проекта", + "achievements": [], + "goals": [], + "links": [], + "region": "Москва", + "industry": 1, + "industry_id": 1, + "presentation_address": null, + "image_address": null, + "collaborators": [ + { + "user_id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "role": "Основатель", + "skills": [], + "avatar": null + }, + { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + } + ], + "partners": [], + "leader": 9, + "draft": false, + "is_company": false, + "vacancies": [], + "resources": [], + "datetime_created": "2026-10-25T14:17:58.811596+03:00", + "datetime_updated": "2026-10-25T14:30:07.718268+03:00", + "views_count": 2, + "is_public": true, + "cover": null, + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "is_default_cover": false, + "actuality": "Проверяем совместимость", + "problem": "Сохраняем существующий сценарий", + "target_audience": "Участники программ", + "implementation_deadline": null, + "trl": null, + "partner_program_tags": [], + "partner_program": null + }, + "cacheControl": null, + "c00": false + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/4/solutions/3/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/projects/4/", + "status": 200, + "request": null, + "response": { + "id": 4, + "team_policy": { + "is_frozen": false, + "program_link_id": 3, + "requires_program_context": false, + "program_links": [ + { + "id": 3, + "program_id": 34, + "is_submitted": false, + "is_competitive": true + } + ] + }, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "description": "Проверка прежнего сценария проекта", + "short_description": "Проверка прежнего сценария проекта", + "achievements": [], + "goals": [], + "links": [], + "region": "Москва", + "industry": 1, + "industry_id": 1, + "presentation_address": null, + "image_address": null, + "collaborators": [ + { + "user_id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "role": "Основатель", + "skills": [], + "avatar": null + } + ], + "partners": [], + "leader": 9, + "draft": true, + "is_company": false, + "vacancies": [], + "resources": [], + "datetime_created": "2026-10-25T14:17:59.067432+03:00", + "datetime_updated": "2026-10-25T14:30:09.944437+03:00", + "views_count": 1, + "is_public": false, + "cover": null, + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "is_default_cover": false, + "actuality": "Проверяем совместимость", + "problem": "Сохраняем существующий сценарий", + "target_audience": "Участники программ", + "implementation_deadline": null, + "trl": null, + "partner_program_tags": [ + "qa01l-20261025-c6c0e219" + ], + "partner_program": { + "name": "Старая программа qa01l-20261025-c6c0e219", + "program_link_id": 3, + "program_id": 34, + "is_submitted": false, + "can_submit": true, + "program_fields": [ + { + "id": 255, + "name": "case", + "label": "Кейс старой программы", + "field_type": "select", + "is_required": true, + "show_filter": true, + "help_text": null, + "options": [ + "Город", + "Образование" + ] + }, + { + "id": 256, + "name": "legacy_summary", + "label": "Описание заявки", + "field_type": "textarea", + "is_required": true, + "show_filter": false, + "help_text": null, + "options": [] + } + ], + "program_field_values": [] + } + }, + "cacheControl": null, + "c00": false + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/6/solutions/4/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/23/solutions/34/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 23, + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-after-d445c996" + }, + "solution": { + "id": 34, + "program_id": 23, + "version": 3, + "name": "Ревизия 1 case_championship", + "captain_id": 9, + "created_at": "2026-10-25T07:36:14.047299Z", + "updated_at": "2026-10-25T07:36:14.283907Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T07:36:14.051623Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 170, + "type": "select", + "label": "Кейс", + "options": [ + "Город", + "Образование" + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 171, + "type": "textarea", + "label": "Краткое описание", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": 3000, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 172, + "type": "file", + "label": "Презентация PDF", + "options": [], + "purpose": "presentation", + "position": 2, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 173, + "type": "url", + "label": "Видео", + "options": [], + "purpose": "video", + "position": 3, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 174, + "type": "file", + "label": "Дополнительные материалы", + "options": [], + "purpose": "other", + "position": 4, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [ + "pdf", + "zip" + ], + "max_file_size_bytes": 52428800 + } + }, + { + "id": 175, + "type": "number", + "label": "Ноль", + "options": [], + "purpose": "other", + "position": 5, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 176, + "type": "checkbox", + "label": "Согласие", + "options": [], + "purpose": "other", + "position": 6, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "020cfc2d9e3e1f0ca820679b917e4bd3cb18be48e9d56e51c5c998c348626c3a", + "template": "case_championship", + "program_id": 23, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 170 + }, + { + "value": "Материалы для экспертной проверки", + "field_id": 171 + }, + { + "value": { + "url": "http://127.0.0.1:8871/local-uploads/55d3c233-2a32-42ed-8187-5c32eae9e963.pdf" + }, + "field_id": 172 + }, + { + "value": "https://example.test/repository", + "field_id": 173 + }, + { + "value": { + "url": "http://127.0.0.1:8871/local-uploads/55d3c233-2a32-42ed-8187-5c32eae9e963.pdf" + }, + "field_id": 174 + }, + { + "value": 0, + "field_id": 175 + }, + { + "value": false, + "field_id": 176 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-28T07:36:13.753456Z", + "submitted_at": "2026-10-25T07:36:14.280895Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "701bea49c2a29ed504f9d699d04dc35cb000411c5d433d4578f2076a82fb116a", + "criteria": [ + { + "id": 39, + "name": "Качество после правки", + "description": "Обоснованность решения", + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 40, + "name": "Реализуемость", + "description": "Возможность реализации", + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 45, + "name": "Новый обязательный критерий", + "description": "Добавлен организатором", + "type": "int", + "min_value": 0.0, + "max_value": 5.0, + "value": "0" + }, + { + "id": 46, + "name": "Новый комментарий", + "description": "Скопируйте прежнее пояснение при необходимости", + "type": "str", + "min_value": null, + "max_value": null, + "value": "КККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККК" + } + ], + "managed_evaluation": true, + "can_rate": false, + "blocked_reason": "Срок оценивания завершен.", + "is_completed": true, + "rated_count": 1, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-25T07:38:41.475473Z" + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/23/solutions/34/evaluation/", + "status": 403, + "request": null, + "response": { + "code": "not_program_expert", + "detail": "Оценивание доступно экспертам этой программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/24/solutions/36/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 24, + "name": "Ревизия оценки Хакатон qa01e-20261025-after-d445c996" + }, + "solution": { + "id": 36, + "program_id": 24, + "version": 3, + "name": "Ревизия 1 hackathon", + "captain_id": 9, + "created_at": "2026-10-25T07:36:15.327392Z", + "updated_at": "2026-10-25T07:36:15.582578Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T07:36:15.331428Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 177, + "type": "select", + "label": "Задача или трек", + "options": [ + "Город", + "Образование" + ], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 178, + "type": "textarea", + "label": "Краткое описание", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": 3000, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 179, + "type": "textarea", + "label": "Описание реализации", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": 10000, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 180, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 181, + "type": "textarea", + "label": "Инструкция запуска", + "options": [], + "purpose": "run_instructions", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 182, + "type": "url", + "label": "Демо", + "options": [], + "purpose": "demo", + "position": 5, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 183, + "type": "file", + "label": "Презентация PDF", + "options": [], + "purpose": "presentation", + "position": 6, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [ + "pdf" + ], + "max_file_size_bytes": 20971520 + } + }, + { + "id": 184, + "type": "url", + "label": "Видео", + "options": [], + "purpose": "video", + "position": 7, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 185, + "type": "file", + "label": "Дополнительные материалы", + "options": [], + "purpose": "other", + "position": 8, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [ + "pdf", + "zip" + ], + "max_file_size_bytes": 52428800 + } + }, + { + "id": 186, + "type": "number", + "label": "Ноль", + "options": [], + "purpose": "other", + "position": 9, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 187, + "type": "checkbox", + "label": "Согласие", + "options": [], + "purpose": "other", + "position": 10, + "required": false, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "64e963bf5d0cfd865de60e7ff952ee3429726c2b8b82a9c78b03ff9de83d5899", + "template": "hackathon", + "program_id": 24, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 177 + }, + { + "value": "Материалы для экспертной проверки", + "field_id": 178 + }, + { + "value": "Материалы для экспертной проверки", + "field_id": 179 + }, + { + "value": "https://example.test/repository", + "field_id": 180 + }, + { + "value": "Материалы для экспертной проверки", + "field_id": 181 + }, + { + "value": "https://example.test/repository", + "field_id": 182 + }, + { + "value": { + "url": "http://127.0.0.1:8871/local-uploads/55d3c233-2a32-42ed-8187-5c32eae9e963.pdf" + }, + "field_id": 183 + }, + { + "value": "https://example.test/repository", + "field_id": 184 + }, + { + "value": { + "url": "http://127.0.0.1:8871/local-uploads/55d3c233-2a32-42ed-8187-5c32eae9e963.pdf" + }, + "field_id": 185 + }, + { + "value": 0, + "field_id": 186 + }, + { + "value": false, + "field_id": 187 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-28T07:36:13.763572Z", + "submitted_at": "2026-10-25T07:36:15.580541Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "7dd87f314b0e3494d680efa04bc27ba72edc1c0dfc8d9bf182f911647769d940", + "criteria": [ + { + "id": 42, + "name": "Качество после правки", + "description": "Обоснованность решения", + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 43, + "name": "Реализуемость", + "description": "Возможность реализации", + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + }, + { + "id": 47, + "name": "Новый обязательный критерий", + "description": "Добавлен организатором", + "type": "int", + "min_value": 0.0, + "max_value": 5.0, + "value": "0" + }, + { + "id": 48, + "name": "Новый комментарий", + "description": "Скопируйте прежнее пояснение при необходимости", + "type": "str", + "min_value": null, + "max_value": null, + "value": "КККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККККК" + } + ], + "managed_evaluation": true, + "can_rate": false, + "blocked_reason": "Срок оценивания завершен.", + "is_completed": true, + "rated_count": 1, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-25T07:41:46.500040Z" + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/24/solutions/36/evaluation/", + "status": 403, + "request": null, + "response": { + "code": "not_program_expert", + "detail": "Оценивание доступно экспертам этой программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/23/solutions/36/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/24/solutions/34/evaluation/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + } + ], + "dualRole": [ + { + "actor": "expert", + "method": "GET", + "path": "/programs/23/management/solution-analytics/", + "status": 200, + "request": null, + "response": { + "program_id": 23, + "summary": { + "participants": { + "total": 3 + }, + "solutions": { + "total": 2 + }, + "experts": { + "total": 2 + }, + "participant_regions": { + "total": 0, + "items": [] + } + }, + "participant_funnel": { + "registrations": 3, + "unique_participants": 3, + "visited_participants": 3, + "onboarded_participants": 3, + "with_team": 2, + "captains": 2, + "submitted_captains": 2 + }, + "solution_funnel": { + "created": 2, + "submission_applicable": true, + "not_submitted": 0, + "submitted": 2, + "evaluated": 0 + }, + "evaluation_status": { + "applicable": true, + "mode": "distributed", + "required_evaluators": 2, + "max_evaluations_per_solution": 1, + "assignments": { + "total": 4, + "pending": 3, + "evaluated": 1 + }, + "solutions": { + "submitted": 2, + "awaiting_evaluation": 1, + "partially_evaluated": 1, + "evaluated": 0 + } + }, + "attention": { + "participants_without_visit": 0, + "participants_without_onboarding": 0, + "participants_without_team": 1, + "solutions_awaiting_evaluation": 2, + "solutions_not_submitted": 0, + "delayed_experts": { + "total": 2, + "items": [ + { + "expert_id": 5, + "user_id": 15, + "first_name": "Роман", + "last_name": "Эксперт", + "full_name": "Роман Эксперт", + "avatar": null, + "assignments_total": 2, + "completed": 1, + "pending": 1, + "overdue_24h": 0, + "overdue_48h": 0, + "oldest_waiting_since": "2026-10-25T07:36:14.792653+00:00", + "oldest_waiting_seconds": 15278, + "severity": "critical" + }, + { + "expert_id": 6, + "user_id": 16, + "first_name": "Алина", + "last_name": "Эксперт", + "full_name": "Алина Эксперт", + "avatar": null, + "assignments_total": 2, + "completed": 0, + "pending": 2, + "overdue_24h": 0, + "overdue_48h": 0, + "oldest_waiting_since": "2026-10-25T07:36:14.328196+00:00", + "oldest_waiting_seconds": 15278, + "severity": "critical" + } + ] + } + }, + "activity": [ + { + "date": "2026-09-26", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-27", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-28", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-29", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-30", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-01", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-02", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-03", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-04", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-05", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-06", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-07", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-08", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-09", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-10", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-11", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-12", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-13", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-14", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-15", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-16", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-17", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-18", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-19", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-20", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-21", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-22", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-23", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-24", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-25", + "registrations": 3, + "submitted_solutions": 2 + } + ], + "cases": { + "program_id": 23, + "configured": true, + "submission_applicable": true, + "items": [ + { + "name": "Город", + "participants_total": 2, + "solutions_total": 2, + "not_submitted": 0, + "submitted": 2 + }, + { + "name": "Образование", + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + ], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + }, + "field_coverage": [ + { + "field_id": 170, + "label": "Кейс", + "type": "select", + "purpose": "case", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 171, + "label": "Краткое описание", + "type": "textarea", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 172, + "label": "Презентация PDF", + "type": "file", + "purpose": "presentation", + "applicable": 2, + "answered": 1, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 173, + "label": "Видео", + "type": "url", + "purpose": "video", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 174, + "label": "Дополнительные материалы", + "type": "file", + "purpose": "other", + "applicable": 2, + "answered": 1, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 175, + "label": "Ноль", + "type": "number", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 176, + "label": "Согласие", + "type": "checkbox", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + } + ] + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/24/management/solution-analytics/", + "status": 200, + "request": null, + "response": { + "program_id": 24, + "summary": { + "participants": { + "total": 3 + }, + "solutions": { + "total": 2 + }, + "experts": { + "total": 2 + }, + "participant_regions": { + "total": 0, + "items": [] + } + }, + "participant_funnel": { + "registrations": 3, + "unique_participants": 3, + "visited_participants": 3, + "onboarded_participants": 3, + "with_team": 2, + "captains": 2, + "submitted_captains": 2 + }, + "solution_funnel": { + "created": 2, + "submission_applicable": true, + "not_submitted": 0, + "submitted": 2, + "evaluated": 1 + }, + "evaluation_status": { + "applicable": true, + "mode": "open", + "required_evaluators": 2, + "max_evaluations_per_solution": 1, + "assignments": null, + "solutions": { + "submitted": 2, + "awaiting_evaluation": 1, + "partially_evaluated": 0, + "evaluated": 1 + } + }, + "attention": { + "participants_without_visit": 0, + "participants_without_onboarding": 0, + "participants_without_team": 1, + "solutions_awaiting_evaluation": 1, + "solutions_not_submitted": 0, + "delayed_experts": { + "total": 0, + "items": [] + } + }, + "activity": [ + { + "date": "2026-09-26", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-27", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-28", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-29", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-09-30", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-01", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-02", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-03", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-04", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-05", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-06", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-07", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-08", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-09", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-10", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-11", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-12", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-13", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-14", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-15", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-16", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-17", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-18", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-19", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-20", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-21", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-22", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-23", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-24", + "registrations": 0, + "submitted_solutions": 0 + }, + { + "date": "2026-10-25", + "registrations": 3, + "submitted_solutions": 2 + } + ], + "cases": { + "program_id": 24, + "configured": true, + "submission_applicable": true, + "items": [ + { + "name": "Город", + "participants_total": 2, + "solutions_total": 2, + "not_submitted": 0, + "submitted": 2 + }, + { + "name": "Образование", + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + ], + "without_case": { + "participants_total": 0, + "solutions_total": 0, + "not_submitted": 0, + "submitted": 0 + } + }, + "field_coverage": [ + { + "field_id": 177, + "label": "Задача или трек", + "type": "select", + "purpose": "case", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 178, + "label": "Краткое описание", + "type": "textarea", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 179, + "label": "Описание реализации", + "type": "textarea", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 180, + "label": "Репозиторий", + "type": "url", + "purpose": "repository", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 181, + "label": "Инструкция запуска", + "type": "textarea", + "purpose": "run_instructions", + "applicable": 2, + "answered": 2, + "required_applicable": 2, + "required_unanswered": 0 + }, + { + "field_id": 182, + "label": "Демо", + "type": "url", + "purpose": "demo", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 183, + "label": "Презентация PDF", + "type": "file", + "purpose": "presentation", + "applicable": 2, + "answered": 1, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 184, + "label": "Видео", + "type": "url", + "purpose": "video", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 185, + "label": "Дополнительные материалы", + "type": "file", + "purpose": "other", + "applicable": 2, + "answered": 1, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 186, + "label": "Ноль", + "type": "number", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + }, + { + "field_id": 187, + "label": "Согласие", + "type": "checkbox", + "purpose": "other", + "applicable": 2, + "answered": 2, + "required_applicable": 0, + "required_unanswered": 0 + } + ] + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/30/management/solution-analytics/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "Аналитика доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/34/management/solution-analytics/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "Аналитика доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/30/management/solution-analytics/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "Аналитика доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store", + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/programs/34/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 34, + "storage_mode": "legacy_project", + "is_registered": false, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_create": false, + "blocked_reason": "legacy_mode", + "current_application": null + }, + "cacheControl": null, + "c00": true + }, + { + "actor": "expert", + "method": "GET", + "path": "/rate-project/34?assigned_only=1", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [], + "evaluation_counts": { + "pending": 0, + "completed": 0 + } + }, + "cacheControl": "no-store", + "c00": false + } + ], + "legacyFinal": [ + { + "method": "GET", + "path": "/projects/3/", + "status": 200, + "response": { + "id": 3, + "team_policy": { + "is_frozen": false, + "program_link_id": null, + "requires_program_context": false, + "program_links": [] + }, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "description": "Проверка прежнего сценария проекта", + "short_description": "Проверка прежнего сценария проекта", + "achievements": [], + "goals": [], + "links": [], + "region": "Москва", + "industry": 1, + "industry_id": 1, + "presentation_address": null, + "image_address": null, + "collaborators": [ + { + "user_id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "role": "Основатель", + "skills": [], + "avatar": null + }, + { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + } + ], + "partners": [], + "leader": 9, + "draft": false, + "is_company": false, + "vacancies": [], + "resources": [], + "datetime_created": "2026-10-25T14:17:58.811596+03:00", + "datetime_updated": "2026-10-25T14:30:07.718268+03:00", + "views_count": 2, + "is_public": true, + "cover": null, + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "is_default_cover": false, + "actuality": "Проверяем совместимость", + "problem": "Сохраняем существующий сценарий", + "target_audience": "Участники программ", + "implementation_deadline": null, + "trl": null, + "partner_program_tags": [], + "partner_program": null + } + }, + { + "method": "GET", + "path": "/invites/?project=3", + "status": 200, + "response": [] + }, + { + "method": "GET", + "path": "/invites/1/", + "status": 200, + "response": { + "id": 1, + "project": { + "id": 3, + "team_policy": { + "is_frozen": false, + "program_link_id": null, + "requires_program_context": false, + "program_links": [] + }, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "draft": false, + "views_count": 2, + "is_company": false, + "partner_program": null + }, + "program_link_id": null, + "user": { + "id": 10, + "user_type": 1, + "email": "qa01-20261025-fa37de87-member@local.example", + "first_name": "Мария", + "last_name": "Сквозная", + "patronymic": null, + "skills": [], + "birthday": "1990-01-01", + "speciality": null, + "v2_speciality": null, + "education": [], + "work_experience": [], + "user_languages": [], + "about_me": null, + "avatar": null, + "links": [], + "city": null, + "is_active": true, + "is_online": false, + "member": { + "useful_to_project": "" + }, + "investor": null, + "expert": null, + "mentor": null, + "achievements": [], + "verification_date": null, + "has_evaluation_access": false, + "onboarding_stage": null, + "projects": [ + { + "id": 3, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "views_count": 2, + "collaborator": { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + }, + "is_company": false + }, + { + "id": 4, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "views_count": 2, + "collaborator": { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + }, + "is_company": false + } + ], + "programs": [ + { + "id": 17, + "tag": "sadm-20261025-1c0f7390-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 18, + "tag": "sadm-20261025-1c0f7390-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 7, + "tag": "qa01-20261025-fa37de87-case_championship", + "name": "QA01 Кейс-чемпионат qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 8, + "tag": "qa01-20261025-fa37de87-hackathon", + "name": "QA01 Хакатон qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 9, + "tag": "rel01-20261025-f7bd7145-case_championship", + "name": "REL01 Кейс-чемпионат rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 10, + "tag": "rel01-20261025-f7bd7145-hackathon", + "name": "REL01 Хакатон rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 11, + "tag": "qa01n-20261025-bdb96b29-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 12, + "tag": "qa01n-20261025-bdb96b29-hackathon", + "name": "QA01 negative Хакатон qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 13, + "tag": "qa01u-20261025-c5cbadd8-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 14, + "tag": "qa01u-20261025-c5cbadd8-hackathon", + "name": "QA01 negative Хакатон qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 15, + "tag": "qa01i-20261025-7ed1e705-case_championship", + "name": "QA01 invitation Кейс-чемпионат qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 16, + "tag": "qa01i-20261025-7ed1e705-hackathon", + "name": "QA01 invitation Хакатон qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 19, + "tag": "sadm-20261025-d2687752-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 20, + "tag": "sadm-20261025-d2687752-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 21, + "tag": "qa01e-20261025-before-1c1271f7-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 22, + "tag": "qa01e-20261025-before-1c1271f7-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 23, + "tag": "qa01e-20261025-after-d445c996-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 24, + "tag": "qa01e-20261025-after-d445c996-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 25, + "tag": "qa01m-20261025-b080f725-case_championship", + "name": "Одновременное вступление Кейс-чемпионат qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 26, + "tag": "qa01m-20261025-b080f725-hackathon", + "name": "Одновременное вступление Хакатон qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 30, + "tag": "qa01c-20261025-2b16a5aa-case_championship", + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 31, + "tag": "qa01c-20261025-2b16a5aa-hackathon", + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 32, + "tag": "qa01c-20261025-2b16a5aa-noncompetitive", + "name": "Смена кейса noncompetitive qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 34, + "tag": "qa01l-20261025-c6c0e219", + "name": "Старая программа qa01l-20261025-c6c0e219", + "year": 2026, + "logo": null + } + ], + "dataset_migration_applied": false, + "is_mospolytech_student": false, + "study_group": null + }, + "sender": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "patronymic": null, + "avatar": null + }, + "motivational_letter": null, + "role": "Аналитик совместимости", + "specialization": null, + "is_accepted": true, + "datetime_created": "2026-10-25T14:29:55.898204+03:00", + "datetime_updated": "2026-10-25T14:29:58.048134+03:00" + } + }, + { + "method": "GET", + "path": "/invites/2/", + "status": 200, + "response": { + "id": 2, + "project": { + "id": 3, + "team_policy": { + "is_frozen": false, + "program_link_id": null, + "requires_program_context": false, + "program_links": [] + }, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "draft": false, + "views_count": 2, + "is_company": false, + "partner_program": null + }, + "program_link_id": null, + "user": { + "id": 11, + "user_type": 1, + "email": "qa01-20261025-fa37de87-pending_a@local.example", + "first_name": "Антон", + "last_name": "Сквозной", + "patronymic": null, + "skills": [], + "birthday": "1990-01-01", + "speciality": null, + "v2_speciality": null, + "education": [], + "work_experience": [], + "user_languages": [], + "about_me": null, + "avatar": null, + "links": [], + "city": null, + "is_active": true, + "is_online": false, + "member": { + "useful_to_project": "" + }, + "investor": null, + "expert": null, + "mentor": null, + "achievements": [], + "verification_date": null, + "has_evaluation_access": false, + "onboarding_stage": null, + "projects": [], + "programs": [ + { + "id": 17, + "tag": "sadm-20261025-1c0f7390-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 18, + "tag": "sadm-20261025-1c0f7390-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 7, + "tag": "qa01-20261025-fa37de87-case_championship", + "name": "QA01 Кейс-чемпионат qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 8, + "tag": "qa01-20261025-fa37de87-hackathon", + "name": "QA01 Хакатон qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 9, + "tag": "rel01-20261025-f7bd7145-case_championship", + "name": "REL01 Кейс-чемпионат rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 10, + "tag": "rel01-20261025-f7bd7145-hackathon", + "name": "REL01 Хакатон rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 11, + "tag": "qa01n-20261025-bdb96b29-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 12, + "tag": "qa01n-20261025-bdb96b29-hackathon", + "name": "QA01 negative Хакатон qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 13, + "tag": "qa01u-20261025-c5cbadd8-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 14, + "tag": "qa01u-20261025-c5cbadd8-hackathon", + "name": "QA01 negative Хакатон qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 15, + "tag": "qa01i-20261025-7ed1e705-case_championship", + "name": "QA01 invitation Кейс-чемпионат qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 16, + "tag": "qa01i-20261025-7ed1e705-hackathon", + "name": "QA01 invitation Хакатон qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 19, + "tag": "sadm-20261025-d2687752-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 20, + "tag": "sadm-20261025-d2687752-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 21, + "tag": "qa01e-20261025-before-1c1271f7-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 22, + "tag": "qa01e-20261025-before-1c1271f7-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 23, + "tag": "qa01e-20261025-after-d445c996-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 24, + "tag": "qa01e-20261025-after-d445c996-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 25, + "tag": "qa01m-20261025-b080f725-case_championship", + "name": "Одновременное вступление Кейс-чемпионат qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 26, + "tag": "qa01m-20261025-b080f725-hackathon", + "name": "Одновременное вступление Хакатон qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 30, + "tag": "qa01c-20261025-2b16a5aa-case_championship", + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 31, + "tag": "qa01c-20261025-2b16a5aa-hackathon", + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 32, + "tag": "qa01c-20261025-2b16a5aa-noncompetitive", + "name": "Смена кейса noncompetitive qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 34, + "tag": "qa01l-20261025-c6c0e219", + "name": "Старая программа qa01l-20261025-c6c0e219", + "year": 2026, + "logo": null + } + ], + "dataset_migration_applied": false, + "is_mospolytech_student": false, + "study_group": null + }, + "sender": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "patronymic": null, + "avatar": null + }, + "motivational_letter": null, + "role": "Проверенная роль", + "specialization": "", + "is_accepted": false, + "datetime_created": "2026-10-25T14:30:01.081577+03:00", + "datetime_updated": "2026-10-25T14:30:03.240526+03:00" + } + }, + { + "method": "GET", + "path": "/projects/4/", + "status": 200, + "response": { + "id": 4, + "team_policy": { + "is_frozen": true, + "program_link_id": 3, + "requires_program_context": false, + "program_links": [ + { + "id": 3, + "program_id": 34, + "is_submitted": true, + "is_competitive": true + } + ] + }, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "description": "Проверка прежнего сценария проекта", + "short_description": "Проверка прежнего сценария проекта", + "achievements": [], + "goals": [], + "links": [], + "region": "Москва", + "industry": 1, + "industry_id": 1, + "presentation_address": null, + "image_address": null, + "collaborators": [ + { + "user_id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "role": "Основатель", + "skills": [], + "avatar": null + }, + { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + } + ], + "partners": [], + "leader": 9, + "draft": false, + "is_company": false, + "vacancies": [], + "resources": [], + "datetime_created": "2026-10-25T14:17:59.067432+03:00", + "datetime_updated": "2026-10-25T14:58:28.811291+03:00", + "views_count": 2, + "is_public": false, + "cover": null, + "cover_image_address": "http://127.0.0.1:8886/assets/images/shared/logo.svg", + "is_default_cover": false, + "actuality": "Проверяем совместимость", + "problem": "Сохраняем существующий сценарий", + "target_audience": "Участники программ", + "implementation_deadline": null, + "trl": null, + "partner_program_tags": [ + "qa01l-20261025-c6c0e219" + ], + "partner_program": { + "name": "Старая программа qa01l-20261025-c6c0e219", + "program_link_id": 3, + "program_id": 34, + "is_submitted": true, + "can_submit": false, + "program_fields": [ + { + "id": 255, + "name": "case", + "label": "Кейс старой программы", + "field_type": "select", + "is_required": true, + "show_filter": true, + "help_text": null, + "options": [ + "Город", + "Образование" + ] + }, + { + "id": 256, + "name": "legacy_summary", + "label": "Описание заявки", + "field_type": "textarea", + "is_required": true, + "show_filter": false, + "help_text": null, + "options": [] + } + ], + "program_field_values": [ + { + "field_name": "case", + "value": "Город" + }, + { + "field_name": "legacy_summary", + "value": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + } + } + }, + { + "method": "GET", + "path": "/invites/?project=4", + "status": 200, + "response": [] + }, + { + "method": "GET", + "path": "/invites/4/", + "status": 200, + "response": { + "id": 4, + "project": { + "id": 4, + "team_policy": { + "is_frozen": true, + "program_link_id": 3, + "requires_program_context": false, + "program_links": [ + { + "id": 3, + "program_id": 34, + "is_submitted": true, + "is_competitive": true + } + ] + }, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "draft": false, + "views_count": 2, + "is_company": false, + "partner_program": { + "id": 34, + "name": "Старая программа qa01l-20261025-c6c0e219" + } + }, + "program_link_id": 3, + "user": { + "id": 10, + "user_type": 1, + "email": "qa01-20261025-fa37de87-member@local.example", + "first_name": "Мария", + "last_name": "Сквозная", + "patronymic": null, + "skills": [], + "birthday": "1990-01-01", + "speciality": null, + "v2_speciality": null, + "education": [], + "work_experience": [], + "user_languages": [], + "about_me": null, + "avatar": null, + "links": [], + "city": null, + "is_active": true, + "is_online": false, + "member": { + "useful_to_project": "" + }, + "investor": null, + "expert": null, + "mentor": null, + "achievements": [], + "verification_date": null, + "has_evaluation_access": false, + "onboarding_stage": null, + "projects": [ + { + "id": 3, + "name": "Обычный проект qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "views_count": 2, + "collaborator": { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + }, + "is_company": false + }, + { + "id": 4, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "views_count": 2, + "collaborator": { + "user_id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "role": "Аналитик совместимости", + "skills": [], + "avatar": null + }, + "is_company": false + } + ], + "programs": [ + { + "id": 17, + "tag": "sadm-20261025-1c0f7390-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 18, + "tag": "sadm-20261025-1c0f7390-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 7, + "tag": "qa01-20261025-fa37de87-case_championship", + "name": "QA01 Кейс-чемпионат qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 8, + "tag": "qa01-20261025-fa37de87-hackathon", + "name": "QA01 Хакатон qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 9, + "tag": "rel01-20261025-f7bd7145-case_championship", + "name": "REL01 Кейс-чемпионат rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 10, + "tag": "rel01-20261025-f7bd7145-hackathon", + "name": "REL01 Хакатон rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 11, + "tag": "qa01n-20261025-bdb96b29-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 12, + "tag": "qa01n-20261025-bdb96b29-hackathon", + "name": "QA01 negative Хакатон qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 13, + "tag": "qa01u-20261025-c5cbadd8-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 14, + "tag": "qa01u-20261025-c5cbadd8-hackathon", + "name": "QA01 negative Хакатон qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 15, + "tag": "qa01i-20261025-7ed1e705-case_championship", + "name": "QA01 invitation Кейс-чемпионат qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 16, + "tag": "qa01i-20261025-7ed1e705-hackathon", + "name": "QA01 invitation Хакатон qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 19, + "tag": "sadm-20261025-d2687752-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 20, + "tag": "sadm-20261025-d2687752-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 21, + "tag": "qa01e-20261025-before-1c1271f7-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 22, + "tag": "qa01e-20261025-before-1c1271f7-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 23, + "tag": "qa01e-20261025-after-d445c996-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 24, + "tag": "qa01e-20261025-after-d445c996-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 25, + "tag": "qa01m-20261025-b080f725-case_championship", + "name": "Одновременное вступление Кейс-чемпионат qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 26, + "tag": "qa01m-20261025-b080f725-hackathon", + "name": "Одновременное вступление Хакатон qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 30, + "tag": "qa01c-20261025-2b16a5aa-case_championship", + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 31, + "tag": "qa01c-20261025-2b16a5aa-hackathon", + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 32, + "tag": "qa01c-20261025-2b16a5aa-noncompetitive", + "name": "Смена кейса noncompetitive qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 34, + "tag": "qa01l-20261025-c6c0e219", + "name": "Старая программа qa01l-20261025-c6c0e219", + "year": 2026, + "logo": null + } + ], + "dataset_migration_applied": false, + "is_mospolytech_student": false, + "study_group": null + }, + "sender": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "patronymic": null, + "avatar": null + }, + "motivational_letter": null, + "role": "Аналитик совместимости", + "specialization": null, + "is_accepted": true, + "datetime_created": "2026-10-25T14:30:12.810403+03:00", + "datetime_updated": "2026-10-25T14:41:20.500490+03:00" + } + }, + { + "method": "GET", + "path": "/invites/5/", + "status": 200, + "response": { + "id": 5, + "project": { + "id": 4, + "team_policy": { + "is_frozen": true, + "program_link_id": 3, + "requires_program_context": false, + "program_links": [ + { + "id": 3, + "program_id": 34, + "is_submitted": true, + "is_competitive": true + } + ] + }, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен", + "leader": 9, + "short_description": "Проверка прежнего сценария проекта", + "image_address": null, + "industry": 1, + "draft": false, + "views_count": 2, + "is_company": false, + "partner_program": { + "id": 34, + "name": "Старая программа qa01l-20261025-c6c0e219" + } + }, + "program_link_id": 3, + "user": { + "id": 11, + "user_type": 1, + "email": "qa01-20261025-fa37de87-pending_a@local.example", + "first_name": "Антон", + "last_name": "Сквозной", + "patronymic": null, + "skills": [], + "birthday": "1990-01-01", + "speciality": null, + "v2_speciality": null, + "education": [], + "work_experience": [], + "user_languages": [], + "about_me": null, + "avatar": null, + "links": [], + "city": null, + "is_active": true, + "is_online": false, + "member": { + "useful_to_project": "" + }, + "investor": null, + "expert": null, + "mentor": null, + "achievements": [], + "verification_date": null, + "has_evaluation_access": false, + "onboarding_stage": null, + "projects": [], + "programs": [ + { + "id": 17, + "tag": "sadm-20261025-1c0f7390-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 18, + "tag": "sadm-20261025-1c0f7390-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-1c0f7390", + "year": 2026, + "logo": null + }, + { + "id": 7, + "tag": "qa01-20261025-fa37de87-case_championship", + "name": "QA01 Кейс-чемпионат qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 8, + "tag": "qa01-20261025-fa37de87-hackathon", + "name": "QA01 Хакатон qa01-20261025-fa37de87", + "year": 2026, + "logo": null + }, + { + "id": 9, + "tag": "rel01-20261025-f7bd7145-case_championship", + "name": "REL01 Кейс-чемпионат rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 10, + "tag": "rel01-20261025-f7bd7145-hackathon", + "name": "REL01 Хакатон rel01-20261025-f7bd7145", + "year": 2026, + "logo": null + }, + { + "id": 11, + "tag": "qa01n-20261025-bdb96b29-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 12, + "tag": "qa01n-20261025-bdb96b29-hackathon", + "name": "QA01 negative Хакатон qa01n-20261025-bdb96b29", + "year": 2026, + "logo": null + }, + { + "id": 13, + "tag": "qa01u-20261025-c5cbadd8-case_championship", + "name": "QA01 negative Кейс-чемпионат qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 14, + "tag": "qa01u-20261025-c5cbadd8-hackathon", + "name": "QA01 negative Хакатон qa01u-20261025-c5cbadd8", + "year": 2026, + "logo": null + }, + { + "id": 15, + "tag": "qa01i-20261025-7ed1e705-case_championship", + "name": "QA01 invitation Кейс-чемпионат qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 16, + "tag": "qa01i-20261025-7ed1e705-hackathon", + "name": "QA01 invitation Хакатон qa01i-20261025-7ed1e705", + "year": 2026, + "logo": null + }, + { + "id": 19, + "tag": "sadm-20261025-d2687752-case_championship", + "name": "Первая сдача Кейс-чемпионат sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 20, + "tag": "sadm-20261025-d2687752-hackathon", + "name": "Первая сдача Хакатон sadm-20261025-d2687752", + "year": 2026, + "logo": null + }, + { + "id": 21, + "tag": "qa01e-20261025-before-1c1271f7-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 22, + "tag": "qa01e-20261025-before-1c1271f7-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-before-1c1271f7", + "year": 2026, + "logo": null + }, + { + "id": 23, + "tag": "qa01e-20261025-after-d445c996-case_championship", + "name": "Ревизия оценки Кейс-чемпионат qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 24, + "tag": "qa01e-20261025-after-d445c996-hackathon", + "name": "Ревизия оценки Хакатон qa01e-20261025-after-d445c996", + "year": 2026, + "logo": null + }, + { + "id": 25, + "tag": "qa01m-20261025-b080f725-case_championship", + "name": "Одновременное вступление Кейс-чемпионат qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 26, + "tag": "qa01m-20261025-b080f725-hackathon", + "name": "Одновременное вступление Хакатон qa01m-20261025-b080f725", + "year": 2026, + "logo": null + }, + { + "id": 30, + "tag": "qa01c-20261025-2b16a5aa-case_championship", + "name": "Смена кейса case_championship qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 31, + "tag": "qa01c-20261025-2b16a5aa-hackathon", + "name": "Смена кейса hackathon qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 32, + "tag": "qa01c-20261025-2b16a5aa-noncompetitive", + "name": "Смена кейса noncompetitive qa01c-20261025-2b16a5aa", + "year": 2026, + "logo": null + }, + { + "id": 34, + "tag": "qa01l-20261025-c6c0e219", + "name": "Старая программа qa01l-20261025-c6c0e219", + "year": 2026, + "logo": null + } + ], + "dataset_migration_applied": false, + "is_mospolytech_student": false, + "study_group": null + }, + "sender": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "patronymic": null, + "avatar": null + }, + "motivational_letter": null, + "role": "Проверенная роль", + "specialization": "", + "is_accepted": false, + "datetime_created": "2026-10-25T14:41:23.572606+03:00", + "datetime_updated": "2026-10-25T14:41:27.180905+03:00" + } + }, + { + "method": "GET", + "path": "/programs/partner-program-projects/3/fields/", + "status": 200, + "response": { + "program_link_id": 3, + "program_id": 34, + "project_id": 4, + "submitted": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_submit": false, + "fields": [ + { + "id": 255, + "name": "case", + "label": "Кейс старой программы", + "field_type": "select", + "is_required": true, + "show_filter": true, + "help_text": null, + "options": [ + "Город", + "Образование" + ], + "value": "Город" + }, + { + "id": 256, + "name": "legacy_summary", + "label": "Описание заявки", + "field_type": "textarea", + "is_required": true, + "show_filter": false, + "help_text": null, + "options": [], + "value": "Старая заявка qa01l-20261025-c6c0e219" + } + ] + } + } + ] +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/README.md b/docs/qa/program-solutions-linux-integration-20261025/README.md new file mode 100644 index 00000000..af1f2fd9 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/README.md @@ -0,0 +1,25 @@ + + +# Linux integration: решения и совместимость с dev + +Статус: **две исходные Linux проверки завершены успешно; полный первый выпуск не завершен**. Это дополнение к [предыдущему compatibility checkpoint](../program-solutions-dev-compatibility-20261025/README.md), а не повторный запуск или переименование старых failed runs. Проверенный feature HEAD — `a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805`. Текущий documentation checkpoint не меняет runtime, contracts или assertions. + +| Проверка | Реальный source scope | Результат | +| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [PostgreSQL CI 40227938298](https://github.com/PROCOLLAB-github/api/actions/runs/40227938298), job121273246458 | Actual checkout `020cc78ce6e45421cb1222c62a98611d8018562c`: родители observed dev `587a9155b9f53d9e50981a9939a7007c19fd6ecc` + feature `a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805` | **1846 tests /2396.359s /OK, без skips**; configuration/models/graph/empty-database migration также success | +| Отдельный locking/vacancy step того же PG job | Тот же synthetic checkout; прежние news migration/vacancy targets, не отдельный отчет только о CPUB races | **39 tests /14.053s /OK**, входят в1846, counts не складываются | +| [General CI 40227933396](https://github.com/PROCOLLAB-github/api/actions/runs/40227933396), Tests121273230836, Lint121273230653 | Feature push source `a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805`, SQLite; это другой source/environment scope | **1812 tests /1863.834s /OK,72 skips**, Lint success; только3 новых skips относятся к PG-only program-lock races | + +[PG evidence](postgres-linux-ci-evidence.json) проверяет terminal receipt, реальный checkout в логе, родителей Git, vendor/select_for_update,0039 и итоговые summaries. Runtime source совпадает с prospective product tree `2b93d6a99be4a1ac250912b59bea7b220ebf195f`: различаются только6 QA files. Полный PG лог использует verbosity1 и не служит отдельным поименным trace каждого теста. [General evidence](general-linux-ci-evidence.json) отдельно подтверждает обе migration orders/graph и фактическое выполнение named Cyrillic A4 PDF renderer test с closing OK. Непустые PDF bytes/token/original rendered_batch уже проверены isolated migration tests; это не данные main rehearsal. + +Публичные [PG](pg-run-receipt.json) и [general](general-run-receipt.json) receipts сохранены вместе с hashes больших raw logs. После Transport closed connector логи terminal jobs получены read-only через gh; workflows не перезапускались. Старые failed runs40223628921 (две leaf nodes) и40223626180 (три SQLite vendor assertions) сохраняют failure. Expanded Windows459 по-прежнему не окончен из-за native FontConfiguration access violation и не объявляется PASS; успешный Linux результат не переписывает эту попытку. + +[Readonly retention после FE checks](retention-after-fe.json): прежние поля/строки27 моделей по-прежнему имеют SHA256 `74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6`, Project4→4. Main не сбрасывался/откатывался, реальные program flags не менялись. Main0 PDF rows, поэтому непустые PDF/backfill assertions имеют отдельный isolated-test scope. Новых миграций или DB действий в этом documentation checkpoint нет. + +[Prospective FE checkpoint](https://github.com/PROCOLLAB-github/procollab/blob/2dc10ce11c207053ba1ab998f51bad07ba27d15e/docs/qa/program-solutions-dev-integration-20261025/README.md):2987 tests/484 files, development build, strict C00 types, ESLint103 и locked format2073 PASS;3681 исходных файла сохранены. Эти проверки не являются новым browser journey и не складываются с прежними2955. Новые synthetic D02/D03 artifacts не меняют Angular. + +Уточнение пользователя: сохраняются семь существующих вкладок и порядок; у solution-программы только «Проекты» → «Заявки». Оценивание внутри заявок, команда второй вкладкой заявки. Участники/Эксперты/Настройки/Сертификаты и остальные сведения не переделываются. Старые Project-программы сохраняют модель, данные, название и маршруты. API/DTO C00 **1.21.0/156 schemas/59 operations/305 scenarios** не меняются. + +[Checkpoint audit](checkpoint-audit.json) фиксирует неизменность всех26 исходных AC rows. **23 PASS; AC18/19 OPEN; AC21 отдельно BE10**. Новый Angular organizer/public UI ожидает уже запрошенного D02/D03 approval; вопрос о несданных конкурсных черновиках открыт. Общий путь expert→manager results, manager counters/filters/XLSX, полная QA01/REL01 приемка и весь первый выпуск остаются в scope. CI success не является READY TO MERGE или разрешением deploy/переключения реальных программ. + +При упаковке первая readonly AC guard ошибочно посчитала также строку заголовка «AC» и остановилась до изменения центральных документов. Выборка исправлена на исходные AC01–AC26; их row SHA и текст совпадают. Сохранены исходный helper/failed output вне Git. Runtime tests не повторялись для documentation-only изменений. diff --git a/docs/qa/program-solutions-linux-integration-20261025/checkpoint-audit.json b/docs/qa/program-solutions-linux-integration-20261025/checkpoint-audit.json new file mode 100644 index 00000000..4e816ce7 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/checkpoint-audit.json @@ -0,0 +1,21 @@ +{ + "originalACRows": 26, + "originalACRowsByteEqual": true, + "originalACRowsSha256": "80be11fa4e7783b0539dfc237182eb3c3b0a2f6a5625f4288409e09a2b87613d", + "contract": "1.21.0/156 schemas/59 operations/305 scenarios; unchanged", + "testedBackendFeature": "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805", + "testedBackendSynthetic": "020cc78ce6e45421cb1222c62a98611d8018562c", + "testedFrontendProspectiveTree": "2f4f4f23b39b198247c3da01685685debf534faa", + "newChanges": "Documentation and synthetic design artifacts only; no production models/services/Angular/contracts changed", + "release": "Incomplete; original AC18/19 OPEN, AC21 BE10 separate", + "noMergeDeployRealProgramSwitch": true, + "files": { + "general-linux-ci-evidence.json": "a4bb467b1bff6d45ebb3e072cc60e1f12f7632dd40263907479c3a50d69b6eb6", + "general-run-receipt.json": "54e5a1bad3d5f45a71572a19a751db1feeb6c6c2c41f2f3ba87dbada272b1e30", + "pg-run-receipt.json": "e5e3e97869be1654aa2865c6ab95461a43071dfdb7b411e3b86680c1734ebc5f", + "postgres-linux-ci-evidence.json": "70478863884986c6ce2312156923f75c0ba466fae11f3fb559887d7aa05b2c6e", + "retention-after-fe.json": "8413fe6251288ed5a6a1a4b507c0caa4571f5825010e74c2a15f332e6b8ffdae" + }, + "operatorDocumentsOriginalPrefixExact": true, + "formattingScope": "New reports formatted; original operator document prefixes restored byte-exact after formatting tried table padding" +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/general-linux-ci-evidence.json b/docs/qa/program-solutions-linux-integration-20261025/general-linux-ci-evidence.json new file mode 100644 index 00000000..0baecec8 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/general-linux-ci-evidence.json @@ -0,0 +1,42 @@ +{ + "status": "PASS", + "runId": 40227933396, + "event": "push", + "head": "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805", + "jobId": 121273230836, + "tests": 1812, + "seconds": 1863.834, + "skips": 72, + "scope": "Linux feature push source, SQLite; merged PostgreSQL suite still separate", + "newPGOnlySkips": 3, + "bothMigrationOrdersAndGraph": true, + "pdfCyrillicRendererExecuted": true, + "proof": { + "test_single_leaf_empty_merge_and_unchanged_upstream_migration": "Tests\tRun tests\t2026-10-25T20:00:03.2479249Z test_single_leaf_empty_merge_and_unchanged_upstream_migration (partner_programs.tests.test_solution_dev_migrations.SolutionDevGraphTests.test_single_leaf_empty_merge_and_unchanged_upstream_migration) ... ok", + "test_certificate_first_preserves_original_pdf_batch_and_solutions": "Tests\tRun tests\t2026-10-25T20:21:38.1599271Z test_certificate_first_preserves_original_pdf_batch_and_solutions (partner_programs.tests.test_solution_dev_migrations.SolutionDevMigrationDataTests.test_certificate_first_preserves_original_pdf_batch_and_solutions) ... ok", + "test_solution_first_preserves_projects_snapshots_scores_and_history": "Tests\tRun tests\t2026-10-25T20:21:48.6742395Z test_solution_first_preserves_projects_snapshots_scores_and_history (partner_programs.tests.test_solution_dev_migrations.SolutionDevMigrationDataTests.test_solution_first_preserves_projects_snapshots_scores_and_history) ... ok", + "test_pdf_renderer_outputs_a4_cyrillic_without_external_fetch": { + "startLine": 4516, + "endLine": 4621, + "closingOK": true, + "notSkipped": true + } + }, + "log": { + "name": "procollab-solution-dev-integration-general-ci-success.log", + "bytes": 33439984, + "sha256": "b798b850674a11fd095d635e4b9d02b52c9033d2916bc0b4421ee5c29d0b29a6" + }, + "retrieval": "gh fallback after connector Transport closed; completed job only", + "lint": "success", + "allJobs": [ + { + "id": 121273230653, + "conclusion": "success" + }, + { + "id": 121273230836, + "conclusion": "success" + } + ] +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/general-run-receipt.json b/docs/qa/program-solutions-linux-integration-20261025/general-run-receipt.json new file mode 100644 index 00000000..eb664d19 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/general-run-receipt.json @@ -0,0 +1,201 @@ +{ + "conclusion": "success", + "databaseId": 40227933396, + "event": "push", + "headSha": "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805", + "jobs": [ + { + "completedAt": "2026-10-25T19:50:43Z", + "conclusion": "success", + "databaseId": 121273230653, + "name": "Lint", + "startedAt": "2026-10-25T19:50:31Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-25T19:50:32Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-25T19:50:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:34Z", + "conclusion": "success", + "name": "Run actions/checkout@v3", + "number": 2, + "startedAt": "2026-10-25T19:50:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:34Z", + "conclusion": "success", + "name": "Set up Python 3.11", + "number": 3, + "startedAt": "2026-10-25T19:50:34Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:39Z", + "conclusion": "success", + "name": "Install lint dependencies", + "number": 4, + "startedAt": "2026-10-25T19:50:34Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:41Z", + "conclusion": "success", + "name": "Run flake8", + "number": 5, + "startedAt": "2026-10-25T19:50:39Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:41Z", + "conclusion": "success", + "name": "Post Set up Python 3.11", + "number": 9, + "startedAt": "2026-10-25T19:50:41Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:42Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v3", + "number": 10, + "startedAt": "2026-10-25T19:50:41Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:42Z", + "conclusion": "success", + "name": "Complete job", + "number": 11, + "startedAt": "2026-10-25T19:50:42Z", + "status": "completed" + } + ], + "url": "https://github.com/PROCOLLAB-github/api/actions/runs/40227933396/job/121273230653" + }, + { + "completedAt": "2026-10-25T20:22:22Z", + "conclusion": "success", + "databaseId": 121273230836, + "name": "Tests", + "startedAt": "2026-10-25T19:50:32Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-25T19:50:34Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-25T19:50:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:35Z", + "conclusion": "success", + "name": "Run actions/checkout@v3", + "number": 2, + "startedAt": "2026-10-25T19:50:34Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:35Z", + "conclusion": "success", + "name": "Set up Python 3.11", + "number": 3, + "startedAt": "2026-10-25T19:50:35Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:37Z", + "conclusion": "success", + "name": "Cache Poetry", + "number": 4, + "startedAt": "2026-10-25T19:50:35Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:38Z", + "conclusion": "success", + "name": "Run snok/install-poetry@v1", + "number": 5, + "startedAt": "2026-10-25T19:50:37Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:42Z", + "conclusion": "success", + "name": "Cache dependencies", + "number": 6, + "startedAt": "2026-10-25T19:50:38Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:50:45Z", + "conclusion": "success", + "name": "Install dependencies", + "number": 7, + "startedAt": "2026-10-25T19:50:42Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:17Z", + "conclusion": "success", + "name": "Run tests", + "number": 8, + "startedAt": "2026-10-25T19:50:45Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:18Z", + "conclusion": "success", + "name": "Post Cache dependencies", + "number": 13, + "startedAt": "2026-10-25T20:22:17Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:19Z", + "conclusion": "success", + "name": "Post Cache Poetry", + "number": 14, + "startedAt": "2026-10-25T20:22:18Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:19Z", + "conclusion": "success", + "name": "Post Set up Python 3.11", + "number": 15, + "startedAt": "2026-10-25T20:22:19Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:19Z", + "conclusion": "success", + "name": "Post Run actions/checkout@v3", + "number": 16, + "startedAt": "2026-10-25T20:22:19Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:22:19Z", + "conclusion": "success", + "name": "Complete job", + "number": 17, + "startedAt": "2026-10-25T20:22:19Z", + "status": "completed" + } + ], + "url": "https://github.com/PROCOLLAB-github/api/actions/runs/40227933396/job/121273230836" + } + ], + "name": "CI", + "status": "completed", + "url": "https://github.com/PROCOLLAB-github/api/actions/runs/40227933396" +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/pg-run-receipt.json b/docs/qa/program-solutions-linux-integration-20261025/pg-run-receipt.json new file mode 100644 index 00000000..cd8d81d1 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/pg-run-receipt.json @@ -0,0 +1,174 @@ +{ + "conclusion": "success", + "databaseId": 40227938298, + "event": "pull_request", + "headSha": "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805", + "jobs": [ + { + "completedAt": "2026-10-25T20:34:15Z", + "conclusion": "success", + "databaseId": 121273246458, + "name": "Backend PostgreSQL CI", + "startedAt": "2026-10-25T19:50:35Z", + "status": "completed", + "steps": [ + { + "completedAt": "2026-10-25T19:50:37Z", + "conclusion": "success", + "name": "Set up job", + "number": 1, + "startedAt": "2026-10-25T19:50:36Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:51:03Z", + "conclusion": "success", + "name": "Initialize containers", + "number": 2, + "startedAt": "2026-10-25T19:50:37Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:51:04Z", + "conclusion": "success", + "name": "Checkout repository", + "number": 3, + "startedAt": "2026-10-25T19:51:03Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:51:04Z", + "conclusion": "success", + "name": "Set up Python", + "number": 4, + "startedAt": "2026-10-25T19:51:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:23Z", + "conclusion": "success", + "name": "Install dependencies", + "number": 5, + "startedAt": "2026-10-25T19:51:04Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:26Z", + "conclusion": "success", + "name": "Verify PostgreSQL connection and locking support", + "number": 6, + "startedAt": "2026-10-25T19:52:23Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:28Z", + "conclusion": "success", + "name": "Check Django configuration", + "number": 7, + "startedAt": "2026-10-25T19:52:26Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:30Z", + "conclusion": "success", + "name": "Check Django models", + "number": 8, + "startedAt": "2026-10-25T19:52:28Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:32Z", + "conclusion": "success", + "name": "Check migration consistency", + "number": 9, + "startedAt": "2026-10-25T19:52:30Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:55Z", + "conclusion": "success", + "name": "Apply migrations to empty PostgreSQL database", + "number": 10, + "startedAt": "2026-10-25T19:52:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:56Z", + "conclusion": "success", + "name": "Check CI settings formatting", + "number": 11, + "startedAt": "2026-10-25T19:52:55Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:52:57Z", + "conclusion": "success", + "name": "Lint CI settings", + "number": 12, + "startedAt": "2026-10-25T19:52:56Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T19:53:32Z", + "conclusion": "success", + "name": "Run PostgreSQL locking and vacancy response tests", + "number": 13, + "startedAt": "2026-10-25T19:52:57Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:10Z", + "conclusion": "success", + "name": "Run full backend test suite", + "number": 14, + "startedAt": "2026-10-25T19:53:32Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:12Z", + "conclusion": "success", + "name": "Remove retained PostgreSQL test database", + "number": 15, + "startedAt": "2026-10-25T20:34:10Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:12Z", + "conclusion": "success", + "name": "Post Set up Python", + "number": 28, + "startedAt": "2026-10-25T20:34:12Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:12Z", + "conclusion": "success", + "name": "Post Checkout repository", + "number": 29, + "startedAt": "2026-10-25T20:34:12Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:13Z", + "conclusion": "success", + "name": "Stop containers", + "number": 30, + "startedAt": "2026-10-25T20:34:12Z", + "status": "completed" + }, + { + "completedAt": "2026-10-25T20:34:13Z", + "conclusion": "success", + "name": "Complete job", + "number": 31, + "startedAt": "2026-10-25T20:34:13Z", + "status": "completed" + } + ], + "url": "https://github.com/PROCOLLAB-github/api/actions/runs/40227938298/job/121273246458" + } + ], + "name": "Backend PostgreSQL CI", + "status": "completed", + "url": "https://github.com/PROCOLLAB-github/api/actions/runs/40227938298" +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/postgres-linux-ci-evidence.json b/docs/qa/program-solutions-linux-integration-20261025/postgres-linux-ci-evidence.json new file mode 100644 index 00000000..34cbf22e --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/postgres-linux-ci-evidence.json @@ -0,0 +1,154 @@ +{ + "status": "PASS", + "runId": 40227938298, + "jobId": 121273246458, + "event": "pull_request", + "head": "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805", + "actualCheckout": "020cc78ce6e45421cb1222c62a98611d8018562c", + "checkoutTree": "809f2c950758407d20cc6d2dbca8db4f48f8970a", + "parents": [ + "587a9155b9f53d9e50981a9939a7007c19fd6ecc", + "a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805" + ], + "prospectiveProductTree": "2b93d6a99be4a1ac250912b59bea7b220ebf195f", + "runtimeSourceParity": true, + "onlyDifferences": [ + "docs/qa/program-solutions-dev-compatibility-20261025/README.md", + "docs/qa/program-solutions-dev-compatibility-20261025/evidence.json", + "docs/qa/program-solutions-dev-compatibility-20261025/main-migration-retention.json", + "docs/qa/program-solutions-dev-compatibility-20261025/native-targets.txt", + "docs/qa/program-solutions-dev-compatibility-20261025/prospective-source-final.json", + "docs/qa/program-solutions-dev-compatibility-20261025/retention-final.json" + ], + "fullSuite": { + "tests": 1846, + "seconds": 2396.359, + "skips": 0, + "command": "poetry run python manage.py test --verbosity 1 --keepdb", + "namedTestTrace": false, + "scope": "Entire Django discovery on synthetic merged source; full-suite log uses verbosity 1, not per-test evidence" + }, + "focusedSuite": { + "tests": 39, + "seconds": 14.053, + "skips": 0, + "includedInFullSuite": true, + "scope": "Existing PostgreSQL locking/news migration/vacancy tests; not the three CPUB races alone" + }, + "databaseVendorVerified": "postgresql", + "selectForUpdateVerified": true, + "steps": [ + { + "name": "Set up job", + "conclusion": "success" + }, + { + "name": "Initialize containers", + "conclusion": "success" + }, + { + "name": "Checkout repository", + "conclusion": "success" + }, + { + "name": "Set up Python", + "conclusion": "success" + }, + { + "name": "Install dependencies", + "conclusion": "success" + }, + { + "name": "Verify PostgreSQL connection and locking support", + "conclusion": "success" + }, + { + "name": "Check Django configuration", + "conclusion": "success" + }, + { + "name": "Check Django models", + "conclusion": "success" + }, + { + "name": "Check migration consistency", + "conclusion": "success" + }, + { + "name": "Apply migrations to empty PostgreSQL database", + "conclusion": "success" + }, + { + "name": "Check CI settings formatting", + "conclusion": "success" + }, + { + "name": "Lint CI settings", + "conclusion": "success" + }, + { + "name": "Run PostgreSQL locking and vacancy response tests", + "conclusion": "success" + }, + { + "name": "Run full backend test suite", + "conclusion": "success" + }, + { + "name": "Remove retained PostgreSQL test database", + "conclusion": "success" + }, + { + "name": "Post Set up Python", + "conclusion": "success" + }, + { + "name": "Post Checkout repository", + "conclusion": "success" + }, + { + "name": "Stop containers", + "conclusion": "success" + }, + { + "name": "Complete job", + "conclusion": "success" + } + ], + "proofLines": [ + "Backend PostgreSQL CI\tCheckout repository\t2026-10-25T19:51:04.3335695Z HEAD is now at 020cc78 Merge a787f0ee7d1cb01f9e777fbe2e0fa60999cc0805 into 587a9155b9f53d9e50981a9939a7007c19fd6ecc", + "Backend PostgreSQL CI\tCheckout repository\t2026-10-25T19:51:04.3410019Z 020cc78ce6e45421cb1222c62a98611d8018562c", + "Backend PostgreSQL CI\tVerify PostgreSQL connection and locking support\t2026-10-25T19:52:25.8372705Z Database vendor: postgresql", + "Backend PostgreSQL CI\tVerify PostgreSQL connection and locking support\t2026-10-25T19:52:25.8373205Z select_for_update support: True", + "Backend PostgreSQL CI\tApply migrations to empty PostgreSQL database\t2026-10-25T19:52:51.3045214Z Applying partner_programs.0039_merge_solution_certificate_branches... OK", + "Backend PostgreSQL CI\tRun PostgreSQL locking and vacancy response tests\t2026-10-25T19:53:13.6700044Z Applying partner_programs.0039_merge_solution_certificate_branches... OK", + "Backend PostgreSQL CI\tRun PostgreSQL locking and vacancy response tests\t2026-10-25T19:53:31.9992852Z Ran 39 tests in 14.053s", + "Backend PostgreSQL CI\tRun PostgreSQL locking and vacancy response tests\t2026-10-25T19:53:31.9993598Z OK", + "Backend PostgreSQL CI\tRun full backend test suite\t2026-10-25T20:33:50.2254978Z Ran 1846 tests in 2396.359s", + "Backend PostgreSQL CI\tRun full backend test suite\t2026-10-25T20:33:50.2255998Z OK" + ], + "log": { + "name": "procollab-solution-dev-integration-pg-ci-success.log", + "bytes": 54397367, + "sha256": "d4f5b20dc5d56c614ac9222686658a0cd11c4d4ff6e27544163604e72ad406c3" + }, + "receipt": { + "name": "procollab-solution-dev-integration-pg-ci-run.json", + "sha256": "83f624feb68a704273961be18d39f5f608a64247d5f819998a125aa354b8e583" + }, + "retrieval": "gh CLI fallback after large-log connector Transport closed; terminal original run, not restarted", + "generalCompanion": { + "run": 40227933396, + "tests": 1812, + "skips": 72, + "namedPDFCyrillicRendererExecuted": true + }, + "notProven": [ + "New Angular organizer/public screens", + "Overall AC18/19", + "QA01/REL01 acceptance", + "Production deployment", + "Real-program conversion" + ], + "countsMustNotBeSummed": true +} diff --git a/docs/qa/program-solutions-linux-integration-20261025/retention-after-fe.json b/docs/qa/program-solutions-linux-integration-20261025/retention-after-fe.json new file mode 100644 index 00000000..0fef3222 --- /dev/null +++ b/docs/qa/program-solutions-linux-integration-20261025/retention-after-fe.json @@ -0,0 +1,121 @@ +{ + "status": "PASS", + "mode": "readonly after final solution regression", + "modelsPreserved": 27, + "hashBefore": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "hashAfter": "74555320f955379af8b51b609d5f27e6e6663fd9a2a8756ad871a9a383103ba6", + "models": { + "partner_programs.PartnerProgram": { + "count": 48, + "hash": "308db2556493ac7c8c989adfa0bd5ca077e6e6edc89784b8401697bd82b40fbb" + }, + "partner_programs.PartnerProgramField": { + "count": 320, + "hash": "a622aed182b162484f79d558b63cea1f3763fcc230e25ea7762c2b75bb150059" + }, + "partner_programs.PartnerProgramUserProfile": { + "count": 135, + "hash": "ceef94cd6ced45827655d53e85c011a2357d5c48e48c928c7e9207e6e1119b96" + }, + "partner_programs.PartnerProgramProject": { + "count": 3, + "hash": "dce4dc89533f63c2135d54aa703d406e7d04b6baebfad64e8d7449a319908cf7" + }, + "partner_programs.PartnerProgramFieldValue": { + "count": 2, + "hash": "416237656955b2207ef23cb5508dade0049e6e916cc20cd311aa46ea4644899c" + }, + "partner_programs.ProgramSolution": { + "count": 70, + "hash": "d454cb8314277a1284b1190cbef363390fce5a54dcba00b93e2966584a34a735" + }, + "partner_programs.ProgramSolutionAnswer": { + "count": 312, + "hash": "1ee34d6e366810e381a0bbf1f220897cdb27c26dbcba9d66eee296a131036a31" + }, + "partner_programs.ProgramSolutionMember": { + "count": 95, + "hash": "600c5db76bd07c73057bd6ead7cf2f96fc14e11a582bd6e82e991c7fb53f57f9" + }, + "partner_programs.SolutionInvitation": { + "count": 47, + "hash": "4e73688be7059ee868b90e9ebdcd1b0bd5e67e03f63fbe1fdf02013face712b9" + }, + "project_rates.SolutionScore": { + "count": 52, + "hash": "60781436579b665a926c05c826decac12b897cedf43befb9fe17972ba6a96171" + }, + "project_rates.SolutionExpertAssignment": { + "count": 20, + "hash": "0717c98555e2d2cf761e088c39816d1f05a020dbc51b52b5e56fd20de47bb15f" + }, + "project_rates.Criteria": { + "count": 91, + "hash": "4221acbca6c7a4e49b0748ceda9d842fefe6e9a01449c7417136538cefbed5be" + }, + "users.Expert": { + "count": 10, + "hash": "673c050537bf88d48674456a8283d0c6e106355a90ff1933f45ce357f10b2dcc" + }, + "files.UserFile": { + "count": 11, + "hash": "844a7ac2122b834d1ad281deded3474cc377cdfbe866037a96e11980c7d0e141" + }, + "projects.Project": { + "count": 4, + "hash": "39f1962a4d9c6ba6b94d626ba944faff0ba1638d47bece52a33ea1f10e877de6" + }, + "projects.Collaborator": { + "count": 6, + "hash": "445691a8d2010219deb06e9fa548a523daff58e5735c48547acaeacc66ab3061" + }, + "invites.Invite": { + "count": 4, + "hash": "450c2b93a53c67c0354f46a7fa73a88bafe09e0939aa0e904c2f572843958146" + }, + "project_rates.ProjectScore": { + "count": 3, + "hash": "3440d0ca3408c76fb67a2d422a0c1cbdf0052767a91f868d1e1b60f4e671c9a3" + }, + "project_rates.ProjectExpertAssignment": { + "count": 1, + "hash": "f8bd082760b34f9b9847593c27541c0a3c9e1bb55d6c9cab45d8b7531bb996d4" + }, + "industries.Industry": { + "count": 1, + "hash": "9c35d62f8b66283b78118a279324d4a04f905e965f46d9a3e9665a020fb532e2" + }, + "users.Expert_programs": { + "count": 36, + "hash": "7123f7d8aff60859ae5d11e0c40bb2aa2c3e40ac0915a78fdf830a3ade7e2c52" + }, + "partner_programs.PartnerProgram_managers": { + "count": 52, + "hash": "dcabadee2fd446601d64c720e7d547c30d4234ba0ed5f17adf98abee62fb5b19" + }, + "partner_programs.SolutionAdministrativeChange": { + "count": 22, + "hash": "33d451a2fdaf0372f28274a23bbb0985a1fe73142f1d7290bcd3a8806358ae8e" + }, + "partner_programs.SolutionRegistrationRemoval": { + "count": 7, + "hash": "f44a714d07b290c0426cf9e8d67976fc6780f2c102e2ba204fe3ac27bdfbf79c" + }, + "users.CustomUser": { + "count": 33, + "hash": "824038a33873b26b1a67c890398eb2f84cb57d17444f087105f42249ff049475" + }, + "partner_programs.ProgramCertificate": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }, + "partner_programs.ProgramCertificateBatch": { + "count": 0, + "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + } + }, + "databaseMutation": false, + "mainReset": false, + "realProgramSwitch": false, + "allPreviouslyPresentColumnsIncluded": true +} diff --git a/docs/qa/program-solutions-member-removal-20261025/README.md b/docs/qa/program-solutions-member-removal-20261025/README.md new file mode 100644 index 00000000..2ef79276 --- /dev/null +++ b/docs/qa/program-solutions-member-removal-20261025/README.md @@ -0,0 +1,26 @@ +# BE08B: исключение участника с сохранением истории + +25.10.2026. Backend product1e037afe301792fb168879f3c32c546585610431, frontend product32fe953667f09ed4d15e9c132bd8a8ae2415ccee. [Нормативный scope](../../product/program-solutions-be08-member-actions.md). C001.18.0:136 схем/53 операции/250 независимых сценариев; три shared files byte-identical.130 прежних схем,51 операция и230 сценариев неизменны; прежняя case event схема сохранена как ветвь union. + +Реализовано signed preview/apply исключения принятого участника до/после сдачи, кроме капитана. Срок/creation flag/текущая форма не блокируют административную операцию. Регистрация, материалы, файлы, оценки, назначения и обработанные приглашения сохраняются; pending выбранного участника отзываются. Submitted snapshot меняет только состав, исходный и новый snapshot и Member остаются в истории. Scoped internal deletion capability не ослабляет ordinary frozen guards и сбрасывается при любом выходе. Ошибка журнала откатывает всю транзакцию. + +Проверено: + +- 371 native PostgreSQL solution tests/OK.18 новых member tests входят в371, в том числе3 observed-lock races confirm×confirm/submit/score. Первый focused run имел4 ошибки тестовых fixtures/assertions: незарегистрированный дополнительный участник, неверный storage mode чужой программы и stale instance после HTTP submit. После исправления fixtures18 новых и полный371 PASS; product writers ради этих failures не ослаблялись. +- 152 legacy Project/invitation/management/case tests/OK по12 модулям: projects.tests.test_competitive_team_policy/test_team_security/test_project_access/test_program_project_detail_access, invites.tests.test_invite_integrity/test_invite_detail_access/test_invite_decisions/test_invite_api/test_models, partner_programs.tests.test_management_project_actions/test_management_application_team_removal/test_case_lifecycle_api. Прежний checkpoint148 имел другой набор labels; числа не складываются. +- 2887 frontend tests/482 files +strict C00 types, development build, scoped ESLint и locked Prettier3.8.3/2023 files PASS до добавления этого отчета.74 case/member/history wire checks включают22 новых member и3 новых history. Unknown query проверяется backend; frontend action client произвольный query не принимает. Invalid user отклоняется frontend до сети, missing-token400 проходит как намеренно invalid request. +- Black/Flake8/Django check/migration consistency/C00/diff checks PASS. Общий backend Linux CI нового product этим checkpoint не объявляется. + +Миграция0036 применена только к local main `procollab_ci` и isolated Unicode test DB. Все прежние строки23 выбранных моделей, включая2 case revisions, совпали до/после: `ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61`. Новые nullable/default поля истории корректны, существующие snapshots и Project не переписывались. Schema-down не является способом отката сохраненной истории. + +Настоящий HTTP на новом backend:57 responses и35 request bodies валидированы по C00. Свежие КЧ38/решение53/ревизия3 и хакатон39/решение54 прошли создание/приглашение/принятие/сохранение/сдачу и однократное исключение. До сдачи получен draft proof; после сдачи он отвергнут как stale. Проверены закрытый срок/creation flag=False, снятая текущая конфигурация КЧ, права, запрет исключения капитана, replay, сохранность int0/boolFalse/материалов/оценок/назначений/регистраций/accepted invite, потеря private доступа исключенного, прежний frozen PATCH и история исходного состава. Legacy context34 остается Project4/link3, новый writer там дает409; чужая пара program/solution дает404. + +После новых journeys прежние строки23 моделей совпадают с тем же baseline. Новых Project=0, новых Solution=2, новых member revisions=2; case revisions1/2 и решения51/52 сохранены. Все новые creation flags в концеFalse, публикацияFalse, real programs не менялись. Первый setup helper ошибся аргументом `role` для test criterion; транзакция полностью откатилась до любых HTTP. Исправленный run не повторял сдачу или подтвержденное исключение. + +`evidence.json` содержит source/helper/raw hashes. `http-wire.json` в Git содержит57 исходных request/response с10 скрытыми signed proof strings; C00 проверялся до redaction. Authorization/JWT/password не записывались. Необработанные wire/logs остаются локально в `design-artifacts/program-solutions-20261024/qa01-member-removal`; private baseline/manifest находятся в TEMP. Собственный API8877 остановлен после проверки; прежние8769/8871/8876 сохранены. + +Предыдущий backend7656b472: General40199193190 завершился success,1730 tests/OK(skipped61), лог сохранен; PG40199197945 на момент упаковки выполнялся. Эти результаты не являются CI нового BE08B. Текущие PR draft/base dev, без merge/deploy/реальных переключений/автоматического переноса. + +Полный первый выпуск остается открыт: снятие/архив заявки, удаление регистрации и retention; D02/D03 approval и соответствующие screens; конкурсные несданные черновики; полный QA01/REL01. Все26 исходных требований сохранены,23 PASS не изменились, AC18/19 OPEN, AC21 отдельно BE10. Новый organizer layout/routes/SCSS не добавлялся. + +Финальная locked format проверка с отчетом:2028 files, failures=[]; sanitized wire форматирован без изменения JSON-значений. diff --git a/docs/qa/program-solutions-member-removal-20261025/contract-evidence.json b/docs/qa/program-solutions-member-removal-20261025/contract-evidence.json new file mode 100644 index 00000000..bab15875 --- /dev/null +++ b/docs/qa/program-solutions-member-removal-20261025/contract-evidence.json @@ -0,0 +1,11 @@ +{ + "version": "1.18.0", + "schemas": 136, + "operations": 53, + "scenarios": 250, + "oldSchemasUnchanged": 130, + "oldScenariosUnchanged": 230, + "oldOperationsUnchanged": 51, + "sharedByteIdentical": 3, + "oldCaseSchemaPreserved": true +} diff --git a/docs/qa/program-solutions-member-removal-20261025/evidence.json b/docs/qa/program-solutions-member-removal-20261025/evidence.json new file mode 100644 index 00000000..eeaf368a --- /dev/null +++ b/docs/qa/program-solutions-member-removal-20261025/evidence.json @@ -0,0 +1,186 @@ +{ + "status": "PASS", + "scope": "BE08B member exclusion and retained revisions, not full QA01/REL01", + "backendProduct": "1e037afe301792fb168879f3c32c546585610431", + "frontendProduct": "32fe953667f09ed4d15e9c132bd8a8ae2415ccee", + "c00": { + "version": "1.18.0", + "schemas": 136, + "operations": 53, + "scenarios": 250, + "oldSchemasUnchanged": 130, + "oldScenariosUnchanged": 230, + "oldOperationsUnchanged": 51, + "sharedByteIdentical": 3, + "oldCaseSchemaPreserved": true + }, + "tests": { + "nativePostgresSolution": 371, + "newNative": 18, + "newObservedRaces": 3, + "legacyRegression": 152, + "frontend": 2887, + "frontendFiles": 482, + "wire": 74, + "newMemberWire": 22, + "newHistoryWire": 3, + "strictTypes": true, + "developmentBuild": true, + "scopedEslint": true, + "black": true, + "flake8": true, + "djangoCheck": true, + "migrationConsistency": true, + "lockedFormat": { + "prettier": "3.8.3", + "locked": "3.8.3", + "checked": 2023, + "failures": [], + "filesWithReport": 2028, + "finalFailures": [] + } + }, + "http": { + "status": "PASS", + "c00": "1.18.0", + "validation": { + "responses": 57, + "validRequests": 35 + }, + "responses": 57, + "programs": [ + { + "program": 38, + "solution": 53, + "change": 3, + "state": { + "accepted": true, + "submitted": true, + "evaluationSeeded": true, + "applied": true, + "complete": true + } + }, + { + "program": 39, + "solution": 54, + "change": 4, + "state": { + "accepted": true, + "submitted": true, + "evaluationSeeded": true, + "applied": true, + "complete": true + } + } + ], + "oldModelsUnchanged": 23, + "oldRowsHash": "ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61", + "newProjects": 0, + "newSolutions": 2, + "newMemberRevisions": 2, + "originalCaseRevisionsPreserved": 2, + "noRealProgramsChanged": true + }, + "migration": { + "status": "PASS", + "migration": "0036_solution_member_admin_history", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61", + "hashAfter": "ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61", + "rowCounts": { + "partner_programs.PartnerProgram": 35, + "partner_programs.PartnerProgramField": 259, + "partner_programs.PartnerProgramUserProfile": 102, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 51, + "partner_programs.ProgramSolutionAnswer": 246, + "partner_programs.ProgramSolutionMember": 69, + "partner_programs.SolutionInvitation": 32, + "project_rates.SolutionScore": 28, + "project_rates.SolutionExpertAssignment": 16, + "project_rates.Criteria": 58, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 21, + "partner_programs.PartnerProgram_managers": 39, + "partner_programs.SolutionAdministrativeChange": 2 + }, + "historyCaseRowsPreserved": 2, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true + }, + "proofRedactions": 10, + "rawArtifacts": { + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\contract-evidence.json": "22e1e63322e4e358eb0523f0e53803d7ae510ac0a6fed355bd284fcf8cf3832a", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\http-evidence.json": "1456cc9a3914199e173326d4534635f6445cbcd01c3e6c7977a5f2dbe12410da", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\http-wire.json": "311015b3a4c6a2d333c607d419b102de8003557b3ab49bb6da164f16a9f7f283", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\migration-evidence.json": "586784a1f62bbe3777a9e9f2ece06c0d91ca830fd10001862e676c9aea82986c", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-black.log": "555e1c5b96b9a5560a570bc43c0342f9785f5038e526f64aa1848a84cb357132", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-build.log": "a7ed32725338e9e194e6dbe54c6e078a85ab7f7a4b52908379ae6767972eda79", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-check.log": "f87ceb15410bde28ff826ce26228e0576558258fa64d145847921a593a61ee63", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-eslint.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-flake8.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-focused.log": "01d12c90abaf62879655a0b323420c3533a3f0c1ff37b078e7ee142b4f7b7c0f", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-format.log": "e86b8d88d750e693566c0b612dcbd9b4bb504e559c34571b0e378768d1ad1b5d", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-frontend-suite.log": "b7c089de3ca4b5164662b87308e935a7fd128e698b8707a4e6418183a313ef33", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-http-resumed.log": "db3880acb0661ccce924d8d7f940b0eada9ff0dea2e7187f64dd072836d69b09", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-http.log": "3fb00bc2f1cd7857f7f24819cd59141d0ff82d563d62718602ec8737a5c01d55", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-legacy.log": "f89c4762ec22bab465df486e379ad329e377afbffeb260073c9de23ca857e31d", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-member-tests.log": "a983d4b862214d059f98f41f7eb78b264320f7b21a5697373f74b1e4c4f655e0", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-migrate.log": "8e22625ff9cdf489f3be02a91f12490d7bd82016c9f3dc34899bd383b8393b44", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-migration-check.log": "93e6e2d5be3c054add80c0f19101608803f7ca8a7df1fa41f76bbb9a1f570f4a", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-preceding-general-ci.log": "0fd9aca95f0626f120d52687d52e3c2864d79c0e5bad5f3d3cb0f1241eaa5c6f", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-solution-suite.log": "6424a5ac99535eb8426ef883af05fdc23cbb92b97a878fd8b89d04cfe66315ff", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-wire.log": "5ebc2483342ab76e2d00785b57a9335c6b0a5fe99324ffcc246bced3152d9c82", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-final-format-verified.log": "a0772a2489808c75967329d6f8ad737fb8d5f355300569bf121d307f16123f36", + "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-member-removal\\procollab-member-removal-final-contract.log": "d52e056d10a5aeab3cc964f1db6e6d4e0caf4335f84034873f0dcc6d60870cc8" + }, + "helpers": { + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-member-removal-contract.py": "25bde53cdf012c028d5f8926bbf72bcd923fa3f065cacbe3bd750cb81ad5c9c1", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-member-removal-migrate.py": "0c18e8019ece0dda1c4f59f48ddebb2ad1edef8e398a00e0d5300c18988d9580", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-member-removal-http.py": "a2fbb149c9a737bc7aca31f60c3cd3839f6bb1a0b4499cd2dc1595c0d14ca62e", + "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-member-removal-package.py": "74293a724b1ce0f1013cfc683a5832a65ce8feba40bf4ab2a875fb1a03786941" + }, + "precedingCi": { + "head": "7656b472e263d5e28539a02ea44c2250944c6efb", + "general": { + "id": 40199193190, + "status": "completed/success", + "tests": 1730, + "skipped": 61 + }, + "postgres": { + "id": 40199197945, + "status": "in_progress" + }, + "notProofForNewProduct": true + }, + "limitations": [ + "Organizer UI requires already-requested D02 approval; no new UI/browser flow", + "Registration removal/detach/archive/retention still pending", + "D03 and competitive unsent publication eligibility pending", + "Full QA01/REL01 remain open, BE10 separate", + "No merge/deploy/real-program switching", + "First focused failures were test fixture registration/storage-mode/stale local instance assertions; corrected before full371 PASS", + "HTTP setup first attempt rolled back completely on helper role argument mismatch; no repeated submission or confirmed removal" + ], + "sourceHashes": { + "partner_programs/solution_models.py": "2f5b93bfab3fe506a80df0fdd0d1b6195d37cc03485eb1bdb1c6221e27cf6988", + "partner_programs/solution_history_api.py": "acf4d716f1777c38b68c4e18c13a2557352f833185e59d5468a0a66e1b34e3ac", + "partner_programs/solution_lifecycle_signals.py": "f2f47cefbbe479fa15d771befb3fdcf0e02b516d06282b7e3341882a402fea8e", + "partner_programs/solution_member_action_api.py": "37bffab4c21bf654dfaf69d6778e6c767cd72c3ce0d251ace3b37c6b3b6fec23", + "partner_programs/services/solution_member_actions.py": "e1fb60cb231404e6173ac1e1ed98c712e0170fd3cba5205cc6b8164db2c4501c", + "partner_programs/migrations/0036_solution_member_admin_history.py": "7334d04203350df3e61a28292555fe46be331b60feebb6427a5f0c8a72baa27b", + "partner_programs/tests/test_solution_member_actions.py": "e843e1ffa241425a6c07ae78fe05999045b61833c0aafd375f3a89ba1bcc89db", + "partner_programs/urls.py": "7eaed255ea776e6bc17ea01420f546430e9bbf8ebde884eed875b73f708912ba" + } +} diff --git a/docs/qa/program-solutions-member-removal-20261025/http-wire.json b/docs/qa/program-solutions-member-removal-20261025/http-wire.json new file mode 100644 index 00000000..c02228b8 --- /dev/null +++ b/docs/qa/program-solutions-member-removal-20261025/http-wire.json @@ -0,0 +1,4763 @@ +[ + { + "actor": "captain", + "method": "POST", + "path": "/programs/38/solutions/", + "status": 201, + "request": { + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "response": { + "id": 53, + "program_id": 38, + "version": 1, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:14.875029Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 38, + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 271, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 271 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 272 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 273 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 274 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/38/solutions/53/", + "status": 200, + "request": null, + "response": { + "id": 53, + "program_id": 38, + "version": 1, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:14.875029Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 38, + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 271, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 271 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 272 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 273 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 274 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/38/solutions/53/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Backend-разработчик" + }, + "response": { + "invitation": { + "id": 56, + "version": 1, + "program": { + "id": 38, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T14:14:15.127265Z", + "updated_at": "2026-10-25T14:14:15.127265Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/56/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 56, + "version": 2, + "program": { + "id": 38, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T14:14:15.127265Z", + "updated_at": "2026-10-25T14:14:15.272157Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/38/solutions/53/", + "status": 200, + "request": null, + "response": { + "id": 53, + "program_id": 38, + "version": 3, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:15.275159Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 38, + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 271, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 271 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 272 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 273 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 274 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/38/solutions/53/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "answers": [ + { + "field_id": 271, + "value": "Город" + }, + { + "field_id": 272, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 273, + "value": 0 + }, + { + "field_id": 274, + "value": false + } + ] + }, + "response": { + "id": 53, + "program_id": 38, + "version": 4, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:15.518344Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 38, + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 271, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 271, + "value": "Город" + }, + { + "field_id": 272, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 273, + "value": 0 + }, + { + "field_id": 274, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 200, + "request": { + "user_id": 10 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "member": { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false + }, + "solution_version": 4, + "is_submitted": false, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 0, + "errors": [], + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/38/solutions/53/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99" + }, + "response": { + "id": 53, + "program_id": 38, + "version": 5, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:15.823471Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "program_id": 38, + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 271, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 271, + "value": "Город" + }, + { + "field_id": 272, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 273, + "value": 0 + }, + { + "field_id": 274, + "value": false + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": "2026-10-25T14:14:15.819475Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/apply/", + "status": 409, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Состав или данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 401, + "request": { + "user_id": 10 + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/38/solutions/53/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 38, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution": { + "id": 53, + "program_id": 38, + "version": 5, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:15.823471Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 271, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "program_id": 38, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 271 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 272 + }, + { + "value": 0, + "field_id": 273 + }, + { + "value": false, + "field_id": 274 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:15.925692Z", + "submitted_at": "2026-10-25T14:14:15.819475Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "137b0820da62b5af55ced3e0d4f27f99e983be14c9c16787a1e35ea8efd89c10", + "criteria": [ + { + "id": 68, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 69, + "name": "Проверка нуля 1", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 70, + "name": "Проверка false 2", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-27T14:14:14.598410Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 200, + "request": { + "user_id": 9 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "member": { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + }, + "solution_version": 5, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [ + { + "path": "user_id", + "code": "captain_cannot_be_removed", + "detail": "Капитана нельзя исключить из состава. Для него снимите заявку с участия.", + "field_id": null + } + ], + "can_apply": false, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/preview/", + "status": 200, + "request": { + "user_id": 10 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false, + "specialization": null + }, + "solution_version": 5, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [], + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/apply/", + "status": 200, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "solution_id": 53, + "solution_version": 6, + "updated_at": "2026-10-25T14:14:16.844983Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/38/management/solutions/53/members/remove/apply/", + "status": 409, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Состав или данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/38/solutions/53/", + "status": 200, + "request": null, + "response": { + "id": 53, + "program_id": 38, + "version": 6, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:16.844983Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 271, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "program_id": 38, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 271 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 272 + }, + { + "value": 0, + "field_id": 273 + }, + { + "value": false, + "field_id": 274 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:15.925692Z", + "submitted_at": "2026-10-25T14:14:15.819475Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/38/solutions/53/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/56/", + "status": 200, + "request": null, + "response": { + "id": 56, + "version": 2, + "program": { + "id": 38, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T14:14:15.127265Z", + "updated_at": "2026-10-25T14:14:15.272157Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/56/accept/", + "status": 409, + "request": { + "expected_version": 2 + }, + "response": { + "code": "invite_already_processed", + "detail": "Приглашение уже обработано.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/38/management/solutions/53/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 3, + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T14:14:16.847006Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false, + "specialization": null + }, + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/38/management/solutions/53/history/3/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 3, + "application": { + "kind": "solution", + "program_id": 38, + "solution_id": 53, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T14:14:16.847006Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false, + "specialization": null + }, + "revoked_invitation_count": 0 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 271, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "program_id": 38, + "is_snapshot": true, + "is_configured": true + }, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "answers": [ + { + "value": "Город", + "field_id": 271 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 272 + }, + { + "value": 0, + "field_id": 273 + }, + { + "value": false, + "field_id": 274 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:15.264093Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T14:14:15.819475Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 271, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "program_id": 38, + "is_snapshot": true, + "is_configured": true + }, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "answers": [ + { + "value": "Город", + "field_id": 271 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 272 + }, + { + "value": 0, + "field_id": 273 + }, + { + "value": false, + "field_id": 274 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T14:14:15.819475Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/38/solutions/53/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 38, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf" + }, + "solution": { + "id": 53, + "program_id": 38, + "version": 6, + "name": "Исключение участника case_championship qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:14.875029Z", + "updated_at": "2026-10-25T14:14:16.844983Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:14.881393Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 271, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 272, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 273, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 274, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "template": "case_championship", + "program_id": 38, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 271 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 272 + }, + { + "value": 0, + "field_id": 273 + }, + { + "value": false, + "field_id": 274 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:15.925692Z", + "submitted_at": "2026-10-25T14:14:15.819475Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "137b0820da62b5af55ced3e0d4f27f99e983be14c9c16787a1e35ea8efd89c10", + "criteria": [ + { + "id": 68, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 69, + "name": "Проверка нуля 1", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 70, + "name": "Проверка false 2", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-27T14:14:14.598410Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/38/solutions/53/", + "status": 409, + "request": { + "expected_version": 6, + "expected_form_revision": "d28254a65cd5a20b50b5b5bd526d32a2567d7e6f19b992093b13d188904b7e99", + "name": "Недоступная правка" + }, + "response": { + "code": "team_frozen", + "detail": "Состав команды зафиксирован после сдачи.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/38/management/solutions/53/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/39/solutions/", + "status": 201, + "request": { + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "response": { + "id": 54, + "program_id": 39, + "version": 1, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:17.988823Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 39, + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 275, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 275 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 276 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 277 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 278 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 279 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/39/solutions/54/", + "status": 200, + "request": null, + "response": { + "id": 54, + "program_id": 39, + "version": 1, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:17.988823Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 39, + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 275, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "team", + "code": "team_min_size", + "detail": "В команде недостаточно принятых участников.", + "field_id": null + }, + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 275 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 276 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 277 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 278 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 279 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/39/solutions/54/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 10, + "role": "Backend-разработчик" + }, + "response": { + "invitation": { + "id": 57, + "version": 1, + "program": { + "id": 39, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T14:14:18.311018Z", + "updated_at": "2026-10-25T14:14:18.311018Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/57/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 57, + "version": 2, + "program": { + "id": 39, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T14:14:18.311018Z", + "updated_at": "2026-10-25T14:14:18.427523Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/39/solutions/54/", + "status": 200, + "request": null, + "response": { + "id": 54, + "program_id": 39, + "version": 3, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:18.431565Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 39, + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 275, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 275 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 276 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 277 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 278 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 279 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/39/solutions/54/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "answers": [ + { + "field_id": 275, + "value": "Город" + }, + { + "field_id": 276, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 277, + "value": 0 + }, + { + "field_id": 278, + "value": false + }, + { + "field_id": 279, + "value": "https://example.com/code/qa01mr-20261025-60d40caf" + } + ] + }, + "response": { + "id": 54, + "program_id": 39, + "version": 4, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:18.634118Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": false + }, + "form": { + "program_id": 39, + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 275, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 275, + "value": "Город" + }, + { + "field_id": 276, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 277, + "value": 0 + }, + { + "field_id": 278, + "value": false + }, + { + "field_id": 279, + "value": "https://example.com/code/qa01mr-20261025-60d40caf" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 200, + "request": { + "user_id": 10 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "member": { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false + }, + "solution_version": 4, + "is_submitted": false, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 0, + "errors": [], + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/39/solutions/54/submit/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b" + }, + "response": { + "id": 54, + "program_id": 39, + "version": 5, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:18.917159Z", + "team": { + "members": [ + { + "user": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true + }, + { + "user": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "program_id": 39, + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 275, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 275, + "value": "Город" + }, + { + "field_id": 276, + "value": "Материалы qa01mr-20261025-60d40caf" + }, + { + "field_id": 277, + "value": 0 + }, + { + "field_id": 278, + "value": false + }, + { + "field_id": 279, + "value": "https://example.com/code/qa01mr-20261025-60d40caf" + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T14:14:14.598410Z", + "submitted_at": "2026-10-25T14:14:18.912690Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/apply/", + "status": 409, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Состав или данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 403, + "request": { + "user_id": 10 + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": null, + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 401, + "request": { + "user_id": 10 + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/39/solutions/54/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 39, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution": { + "id": 54, + "program_id": 39, + "version": 5, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:18.917159Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false, + "specialization": null + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 275, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "program_id": 39, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 275 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 276 + }, + { + "value": 0, + "field_id": 277 + }, + { + "value": false, + "field_id": 278 + }, + { + "value": "https://example.com/code/qa01mr-20261025-60d40caf", + "field_id": 279 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:19.007608Z", + "submitted_at": "2026-10-25T14:14:18.912690Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "f98a11a2db7f5de736fd8f188480f615ee65a65cce0549963672829792a9fbcd", + "criteria": [ + { + "id": 71, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 72, + "name": "Проверка нуля 3", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 73, + "name": "Проверка false 4", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-27T14:14:14.598410Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 200, + "request": { + "user_id": 9 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "member": { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + }, + "solution_version": 5, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [ + { + "path": "user_id", + "code": "captain_cannot_be_removed", + "detail": "Капитана нельзя исключить из состава. Для него снимите заявку с участия.", + "field_id": null + } + ], + "can_apply": false, + "preview_token": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/preview/", + "status": 200, + "request": { + "user_id": 10 + }, + "response": { + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false, + "specialization": null + }, + "solution_version": 5, + "is_submitted": true, + "remaining_member_count": 1, + "revoked_invitation_count": 0, + "score_count": 2, + "errors": [], + "can_apply": true, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/apply/", + "status": 200, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "solution_id": 54, + "solution_version": 6, + "updated_at": "2026-10-25T14:14:19.926401Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/39/management/solutions/54/members/remove/apply/", + "status": 409, + "request": { + "user_id": 10, + "preview_token": "[REDACTED_SIGNED_PREVIEW]" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Состав или данные решения изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/39/solutions/54/", + "status": 200, + "request": null, + "response": { + "id": 54, + "program_id": 39, + "version": 6, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:19.926401Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 275, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "program_id": 39, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 275 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 276 + }, + { + "value": 0, + "field_id": 277 + }, + { + "value": false, + "field_id": 278 + }, + { + "value": "https://example.com/code/qa01mr-20261025-60d40caf", + "field_id": 279 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:19.007608Z", + "submitted_at": "2026-10-25T14:14:18.912690Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/39/solutions/54/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/57/", + "status": 200, + "request": null, + "response": { + "id": 57, + "version": 2, + "program": { + "id": 39, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "captain": { + "id": 9, + "first_name": "Илья", + "last_name": "Сквозной", + "avatar_url": null + }, + "recipient": { + "id": 10, + "first_name": "Мария", + "last_name": "Сквозная", + "avatar_url": null + }, + "role": "Backend-разработчик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T14:14:18.311018Z", + "updated_at": "2026-10-25T14:14:18.427523Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/57/accept/", + "status": 409, + "request": { + "expected_version": 2 + }, + "response": { + "code": "invite_already_processed", + "detail": "Приглашение уже обработано.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/39/management/solutions/54/history/", + "status": 200, + "request": null, + "response": { + "count": 1, + "offset": 0, + "limit": 20, + "results": [ + { + "id": 4, + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T14:14:19.927302Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false, + "specialization": null + }, + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/39/management/solutions/54/history/4/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 4, + "application": { + "kind": "solution", + "program_id": 39, + "solution_id": 54, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "action": "remove_member", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T14:14:19.927302Z", + "before_version": 5, + "after_version": 6, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_member": { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false, + "specialization": null + }, + "revoked_invitation_count": 0 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 275, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "program_id": 39, + "is_snapshot": true, + "is_configured": true + }, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "answers": [ + { + "value": "Город", + "field_id": 275 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 276 + }, + { + "value": 0, + "field_id": 277 + }, + { + "value": false, + "field_id": 278 + }, + { + "value": "https://example.com/code/qa01mr-20261025-60d40caf", + "field_id": 279 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Backend-разработчик", + "user": { + "id": 10, + "last_name": "Сквозная", + "avatar_url": null, + "first_name": "Мария" + }, + "joined_at": "2026-10-25T14:14:18.421437Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T14:14:18.912690Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 275, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "program_id": 39, + "is_snapshot": true, + "is_configured": true + }, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "answers": [ + { + "value": "Город", + "field_id": 275 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 276 + }, + { + "value": 0, + "field_id": 277 + }, + { + "value": false, + "field_id": 278 + }, + { + "value": "https://example.com/code/qa01mr-20261025-60d40caf", + "field_id": 279 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 9, + "submitted_at": "2026-10-25T14:14:18.912690Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "rated", + "method": "GET", + "path": "/programs/39/solutions/54/evaluation/", + "status": 200, + "request": null, + "response": { + "program": { + "id": 39, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf" + }, + "solution": { + "id": 54, + "program_id": 39, + "version": 6, + "name": "Исключение участника hackathon qa01mr-20261025-60d40caf", + "captain_id": 9, + "created_at": "2026-10-25T14:14:17.988823Z", + "updated_at": "2026-10-25T14:14:19.926401Z", + "team": { + "members": [ + { + "role": null, + "user": { + "id": 9, + "last_name": "Сквозной", + "avatar_url": null, + "first_name": "Илья" + }, + "joined_at": "2026-10-25T14:14:17.992981Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 2, + "max_size": 3, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 275, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 276, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 277, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 278, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 279, + "type": "url", + "label": "Репозиторий", + "options": [], + "purpose": "repository", + "position": 4, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "template": "hackathon", + "program_id": 39, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 275 + }, + { + "value": "Материалы qa01mr-20261025-60d40caf", + "field_id": 276 + }, + { + "value": 0, + "field_id": 277 + }, + { + "value": false, + "field_id": 278 + }, + { + "value": "https://example.com/code/qa01mr-20261025-60d40caf", + "field_id": 279 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T14:14:19.007608Z", + "submitted_at": "2026-10-25T14:14:18.912690Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "criteria_revision": "f98a11a2db7f5de736fd8f188480f615ee65a65cce0549963672829792a9fbcd", + "criteria": [ + { + "id": 71, + "name": "Комментарий", + "description": "Доп. поле для впечатлений о проекте", + "type": "str", + "min_value": null, + "max_value": null, + "value": null + }, + { + "id": 72, + "name": "Проверка нуля 3", + "description": null, + "type": "int", + "min_value": 0.0, + "max_value": 10.0, + "value": "0" + }, + { + "id": 73, + "name": "Проверка false 4", + "description": null, + "type": "bool", + "min_value": null, + "max_value": null, + "value": "False" + } + ], + "managed_evaluation": true, + "can_rate": true, + "blocked_reason": null, + "is_completed": false, + "rated_count": 0, + "max_rates": 1, + "required_evaluators": 2, + "deadline": "2026-10-27T14:14:14.598410Z" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/39/solutions/54/", + "status": 409, + "request": { + "expected_version": 6, + "expected_form_revision": "f3ff0a58557e6667d16c308067b4562d96cbf7a18368465cd5d439fd6ce94e8b", + "name": "Недоступная правка" + }, + "response": { + "code": "team_frozen", + "detail": "Состав команды зафиксирован после сдачи.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/39/management/solutions/54/history/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "История доступна организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/34/solution-context/", + "status": 200, + "request": null, + "response": { + "program_id": 34, + "storage_mode": "legacy_project", + "is_registered": true, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-26T11:17:58.692009Z", + "can_create": false, + "blocked_reason": "already_has_application", + "current_application": { + "kind": "legacy_project", + "program_id": 34, + "project_id": 4, + "program_link_id": 3, + "name": "Заявка проекта qa01l-20261025-c6c0e219 сохранен" + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/34/management/solutions/53/members/remove/preview/", + "status": 409, + "request": { + "user_id": 10 + }, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/38/management/solutions/54/history/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + } +] diff --git a/docs/qa/program-solutions-member-removal-20261025/migration-evidence.json b/docs/qa/program-solutions-member-removal-20261025/migration-evidence.json new file mode 100644 index 00000000..487b998d --- /dev/null +++ b/docs/qa/program-solutions-member-removal-20261025/migration-evidence.json @@ -0,0 +1,36 @@ +{ + "status": "PASS", + "migration": "0036_solution_member_admin_history", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61", + "hashAfter": "ce0a2edae7fe3e68846a097634c0b39364512edb77e32ada8166e860c82bbd61", + "rowCounts": { + "partner_programs.PartnerProgram": 35, + "partner_programs.PartnerProgramField": 259, + "partner_programs.PartnerProgramUserProfile": 102, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 51, + "partner_programs.ProgramSolutionAnswer": 246, + "partner_programs.ProgramSolutionMember": 69, + "partner_programs.SolutionInvitation": 32, + "project_rates.SolutionScore": 28, + "project_rates.SolutionExpertAssignment": 16, + "project_rates.Criteria": 58, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 21, + "partner_programs.PartnerProgram_managers": 39, + "partner_programs.SolutionAdministrativeChange": 2 + }, + "historyCaseRowsPreserved": 2, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true +} diff --git a/docs/qa/program-solutions-membership-races-20261025/README.md b/docs/qa/program-solutions-membership-races-20261025/README.md new file mode 100644 index 00000000..119d1049 --- /dev/null +++ b/docs/qa/program-solutions-membership-races-20261025/README.md @@ -0,0 +1,33 @@ +# QA01: одна заявка капитана и одна команда участника + +AC02 и AC08 проверены на реальных HTTP, двух независимых API-процессах и PostgreSQL. Обычный вход капитанов и участника через Angular открывает существующую заявку, в том числе при выключенном создании. Продуктовый код этого checkpoint не менялся. Общие QA01/REL01 и первый выпуск остаются незавершенными. + +Backend source: `30c0d3ec1ac8e1236dd669205b97b4a2b4463fda`; frontend: `d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175`; C00 `1.14.0`. Только новые синтетические программы 25/26, решения 38–41, prefix `qa01m-20261025-b080f725`. API 8877/8878, SPA 8881, PostgreSQL 18/main procollab_ci; locmem mail и блокировка внешних запросов браузера. Пароли/JWT/credential manifest не включены в Git. + +| Проверка | Наблюдаемый результат | Evidence | +| --- | --- | --- | +| MR01 / AC02: одновременное создание | В каждой программе два POST от одного капитана через разные API. До освобождения строки программы наблюдались два разных PostgreSQL PID и пути `pg_blocking_pids` к контроллеру. Ответы 201/200 с одним ID; в БД одна заявка этого капитана и одна начальная запись состава. | [HTTP и четыре race proof](http-evidence.json) | +| MR02: повторное создание | Повтор POST с другим названием возвращает прежний ID, название и version=1. После выключения creation повтор тоже открывает существующую заявку. | HTTP, context GET и независимый verifier | +| MR03 / AC02: обычный вход | «мое решение» открывает правильный current_application обеих программ для двух капитанов и принятого участника; creation=False, автоматических solution POST нет. Команда находится во второй вкладке той же заявки. | [8 browser checks](browser-checks.json) | +| MR04 / AC08: два приглашения | Одному человеку направлены приглашения в две разные команды активной программы. Два одновременных accept через независимые API достигли реальной program lock до освобождения контроллера. Один 200, второй 409/already_in_program_team; ровно одно членство. | HTTP и DB verifier | +| MR05: повтор проигравшего и отклонение | Повтор accept проигравшего с актуальной invitation version=1 снова дает 409 без изменения pending/version. Обычный decline затем дает 200/declined/version=2. Победившее accepted членство сохраняется. | HTTP; final invitation IDs/status в verifier | +| MR06: изоляция и доступ | Принятый участник открывает победившую заявку; другая команда после decline дает 404. Редактор названия и приглашение участнику не доступны. Одинаковые аккаунты участвуют в двух программах без смешения solution ID. | Browser checks и C00 GET | +| MR07: контракт и прежние данные | 31 HTTP response / 24 JSON requests и 16 browser responses соответствуют C00. Три contract файла byte-identical в repos. Прежние строки 15 выбранных domain моделей совпали с baseline; Project не появился. | [Независимый verifier](domain-and-contract-evidence.json) | + +Конкурентные проверки не подменены двумя последовательными вызовами: каждая из четырех гонок наблюдает оба живых запроса до освобождения настоящей блокировки строки программы. Второй worker может ждать первого, поэтому сохранены транзитивные пути к контроллеру. Это проверка указанных инвариантов, не нагрузочный benchmark и не доказательство всех AC15 submit/save/team/form races. + +Прочитаны конкретные assertions текущих `SolutionCreationConcurrencyTests.test_concurrent_create_serializes_on_program_and_returns_one_solution` и `SolutionInvitationConcurrencyTests.test_acceptance_of_two_solution_invitations_only_joins_one_team`: они проверяют один ID/создание/Project и одно принятое членство с already_in_program_team, используют настоящие PostgreSQL `TransactionTestCase`/Barrier. Новая HTTP приемка добавляет авторизацию, wire contract и наблюдаемые lock paths. Эти test modules не менялись; текущий backend CI 30c0d3e уже включает их. Локальный test DB или полный suite заново ради evidence не запускались. + +## Происхождение и ограничения + +Начальный helper прочитал revision у envelope вместо form.revision и остановился после одного GET. PUT/создание решения еще не выполнялись. Исходный FAIL сохранен; продолжение явно проверило prefix, ID программ 25/26, отсутствие решений/полей, выключенные flags и пустую историю гонок. Программы и данные не пересоздавались. + +Первый browser attempt выполнил оба капитанских входа, но во втором отдельном контексте не появилась форма login; JavaScript exception не зафиксирован, причина не установлена. Повтор с диагностикой завершил все восемь продуктовых проверок, затем упал на ошибочном ожидании вообще нулевых program POST: фактически были четыре штатных `acknowledge-welcome` от двух новых участников двух программ. Эти записи явно сохранены в отчете. Verifier заново проверил контракт и БД, классифицировал их отдельно; новая попытка для получения искусственного zero-write результата не выполнялась. В повторном browser attempt JavaScript exceptions=0; это не отменяет первого сбоя. + +Основной browser путь 390 px; шесть состояний команды проверены на 320/390/768/1440 без горизонтального overflow. Скриншоты captain КЧ и member хакатона просмотрены; остальные retained локально. Remote avatars намеренно блокировались и могут выглядеть незагруженными. Browser не повторяет создание или accept двумя человеческими кликами: это отдельный реальный HTTP race, за которым следует обычный UI вход. Полные desktop flows, a11y/zoom, нагрузка, production CDN, manager/public UI и весь первый выпуск не заявляются. + +Build reused из предыдущего ER checkpoint: он был собран до commit d2a2cd3f при HEAD 091d1ee1. Единственный runtime diff между refs — уже включенный expert-frame fix, его source hash совпадает; spec не является runtime. Проверены сохраненные source hashes и один patched generated origins chunk. Исходные environments не менялись. [Manifest](local-artifacts.json) хранит SHA256 raw wire, FAIL reports, screenshots и helpers. + +Финально в каждой программе по две draft заявки, всего три записи состава; у проверяемого участника ровно одна команда. Accepted/declined invitation history сохранена; pending=0. Ответов, сдачи/snapshot, публикации, оценок и назначений нет; creation flags=False. Независимый verifier не менял выбранные domain rows. Он не включает auth/session/last_activity в утверждение неизменности. + +Merge/deploy, новые migrations, перенос старых программ и переключение реальных программ не выполнялись. D02/D03, политика административных исключений после сдачи и конкурсной публикации, остальные QA01/REL01 остаются открытыми. diff --git a/docs/qa/program-solutions-membership-races-20261025/browser-checks.json b/docs/qa/program-solutions-membership-races-20261025/browser-checks.json new file mode 100644 index 00000000..2f4bd054 --- /dev/null +++ b/docs/qa/program-solutions-membership-races-20261025/browser-checks.json @@ -0,0 +1,185 @@ +{ + "prefix": "qa01m-20261025-b080f725", + "backend": "30c0d3ec1ac8e1236dd669205b97b4a2b4463fda", + "frontend": "d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175", + "build": { + "provenanceHead": "091d1ee11b64f5c1037a1a3213d66029355a05c1", + "runtimeSourceHashesMatch": true, + "runtimeDiff": [ + "projects/social_platform/src/app/ui/pages/program/solution-evaluation/solution-evaluation.component.spec.ts", + "projects/social_platform/src/app/ui/pages/program/solution-evaluation/solution-evaluation.component.ts" + ], + "generatedOriginOnly": [ + { + "file": "chunk-CHX6T2XY.js", + "before": "4707c125db5485fa8b5eeb9e30e23c6f90759d9f2daf86b56a5a0b332da287dd", + "after": "d8252699c1d7f5986df9684f6f5b9be81943d068050e6537958be0680cac6380" + } + ] + }, + "checks": [ + { + "program": 25, + "template": "case_championship", + "role": "captain", + "solution": 38, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 2, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 26, + "template": "hackathon", + "role": "captain", + "solution": 40, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 2, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 25, + "template": "case_championship", + "role": "pending_a", + "solution": 39, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 1, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 26, + "template": "hackathon", + "role": "pending_a", + "solution": 41, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 1, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 25, + "template": "case_championship", + "role": "member", + "solution": 38, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 2, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 26, + "template": "hackathon", + "role": "member", + "solution": 40, + "check": "Normal entry opens actual current application with creation disabled; team is inside same application", + "accepted": 2, + "pending": 0, + "responsive": [ + 320, + 390, + 768, + 1440 + ] + }, + { + "program": 25, + "role": "member", + "check": "Other team remains inaccessible after losing acceptance and declining invitation", + "solution": 39, + "status": 404 + }, + { + "program": 26, + "role": "member", + "check": "Other team remains inaccessible after losing acceptance and declining invitation", + "solution": 41, + "status": 404 + } + ], + "javascriptErrors": [], + "diagnostics": { + "rawEntryCount": 104, + "note": "Raw browser diagnostics retained locally and hashed; intentionally blocked remote avatar requests may appear as console resource errors." + }, + "limits": [ + "Read-only normal-entry checks after real HTTP races; acceptance is tested by concurrent HTTP, not two human clicks.", + "Reused build was generated before its source commit; changed runtime source hashes match current commit. Only one generated origins chunk is patched.", + "External requests blocked; no full accessibility, zoom, load or production CDN audit." + ], + "status": "PASS for completed product checks", + "sourceArtifact": "browser-attempt-1792917425290.json", + "solutionWrites": [], + "welcomeAcknowledgementRequests": [ + { + "role": "pending_a", + "method": "POST", + "path": "/programs/25/acknowledge-welcome/" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/26/acknowledge-welcome/" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/25/acknowledge-welcome/" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/26/acknowledge-welcome/" + } + ], + "allProgramMutationRequests": [ + { + "role": "pending_a", + "method": "POST", + "path": "/programs/25/acknowledge-welcome/" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/26/acknowledge-welcome/" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/25/acknowledge-welcome/" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/26/acknowledge-welcome/" + } + ], + "harnessFailure": "AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:\n+ actual - expected\n\n+ [\n+ {\n+ method: 'POST',\n+ path: '/programs/25/acknowledge-welcome/',\n+ role: 'pending_a'\n+ },\n+ {\n+ method: 'POST',\n+ path: '/programs/26/acknowledge-welcome/',\n+ role: 'pending_a'\n+ },\n+ {\n+ method: 'POST',\n+ path: '/programs/25/acknowledge-welcome/',\n+ role: 'member'\n+ },\n+ {\n+ method: 'POST',\n+ path: '/programs/26/acknowledge-welcome/',\n+ role: 'member'\n+ }\n+ ]\n- []\n\n at C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-browser.cjs:132:71", + "provenance": "All 8 product checks completed in the diagnostic browser attempt. Its final zero-program-writes assertion incorrectly included 4 legitimate welcome acknowledgements. Raw FAIL retained; this verifier classifies these requests explicitly and validates all captured C00 reads. No new browser run or solution writes were used to manufacture a zero-write result." +} diff --git a/docs/qa/program-solutions-membership-races-20261025/domain-and-contract-evidence.json b/docs/qa/program-solutions-membership-races-20261025/domain-and-contract-evidence.json new file mode 100644 index 00000000..d0118e60 --- /dev/null +++ b/docs/qa/program-solutions-membership-races-20261025/domain-and-contract-evidence.json @@ -0,0 +1,76 @@ +{ + "status": "PASS", + "backend": "30c0d3ec1ac8e1236dd669205b97b4a2b4463fda", + "frontend": "d2a2cd3f80549e7dcbc6b3e32e55fea7ead57175", + "c00": "1.14.0", + "prefix": "qa01m-20261025-b080f725", + "validation": { + "httpResponses": 31, + "httpRequests": 24, + "browserResponses": 16, + "browserRequests": 0 + }, + "programs": [ + { + "program": 25, + "template": "case_championship", + "solutions": [ + 38, + 39 + ], + "winningSolution": 38, + "acceptedInvitation": 47, + "declinedInvitation": 48, + "memberTeams": 1, + "draftSolutions": 2, + "pendingInvitations": 0, + "creationFlagOff": true + }, + { + "program": 26, + "template": "hackathon", + "solutions": [ + 40, + 41 + ], + "winningSolution": 40, + "acceptedInvitation": 49, + "declinedInvitation": 50, + "memberTeams": 1, + "draftSolutions": 2, + "pendingInvitations": 0, + "creationFlagOff": true + } + ], + "races": 4, + "browserChecks": 8, + "welcomeAcknowledgementRequests": 4, + "solutionMutationRequestsInBrowser": 0, + "preexistingSelectedDomainUnchanged": true, + "selectedDomainModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "verifierDomainMutations": 0, + "contractCopiesByteIdentical": true, + "projectCountUnchanged": true, + "observedLimitations": [ + "Initial setup failed before any form write because harness used revision instead of form.revision; same empty fixtures resumed under explicit guards.", + "First browser attempt completed captain checks, then showed an empty login page without a JS exception for a second context; cause is not established.", + "Second browser attempt completed all product checks but failed an overly broad zero-program-writes assertion; welcome acknowledgements are explicitly retained.", + "Remote avatar requests intentionally blocked; no full UI lifecycle, accessibility, zoom, load or production check." + ] +} diff --git a/docs/qa/program-solutions-membership-races-20261025/http-evidence.json b/docs/qa/program-solutions-membership-races-20261025/http-evidence.json new file mode 100644 index 00000000..facaa3d7 --- /dev/null +++ b/docs/qa/program-solutions-membership-races-20261025/http-evidence.json @@ -0,0 +1,556 @@ +{ + "status": "PASS", + "source": "30c0d3ec1ac8e1236dd669205b97b4a2b4463fda", + "c00": "1.14.0", + "prefix": "qa01m-20261025-b080f725", + "programs": [ + { + "id": 25, + "template": "case_championship", + "solutionId": 38, + "otherSolutionId": 39, + "invitationIds": [ + 47, + 48 + ], + "winningSolutionId": 38, + "winnerInvitationId": 47, + "declinedInvitationId": 48, + "creationRaceStatuses": [ + 200, + 201 + ], + "joinRaceStatuses": [ + 200, + 409 + ], + "creationFlagOff": true + }, + { + "id": 26, + "template": "hackathon", + "solutionId": 40, + "otherSolutionId": 41, + "invitationIds": [ + 49, + 50 + ], + "winningSolutionId": 40, + "winnerInvitationId": 49, + "declinedInvitationId": 50, + "creationRaceStatuses": [ + 200, + 201 + ], + "joinRaceStatuses": [ + 200, + 409 + ], + "creationFlagOff": true + } + ], + "races": [ + { + "program": 25, + "kind": "AC02 concurrent create", + "controllerPid": 19032, + "blockedWorkers": [ + { + "pid": 25196, + "pathToController": [ + 25196, + 19032 + ] + }, + { + "pid": 24644, + "pathToController": [ + 24644, + 25196, + 19032 + ] + } + ], + "origins": [ + "http://127.0.0.1:8877", + "http://127.0.0.1:8878" + ], + "results": [ + { + "status": 201, + "code": null, + "id": 38, + "sha256": "799910d5fdfd0a1f9707ed90b8a76510009e94e66c34eaab3cfc48fd60489e71" + }, + { + "status": 200, + "code": null, + "id": 38, + "sha256": "799910d5fdfd0a1f9707ed90b8a76510009e94e66c34eaab3cfc48fd60489e71" + } + ] + }, + { + "program": 25, + "kind": "AC08 accept different teams", + "controllerPid": 19032, + "blockedWorkers": [ + { + "pid": 20344, + "pathToController": [ + 20344, + 19032 + ] + }, + { + "pid": 2240, + "pathToController": [ + 2240, + 20344, + 19032 + ] + } + ], + "origins": [ + "http://127.0.0.1:8877", + "http://127.0.0.1:8878" + ], + "results": [ + { + "status": 200, + "code": null, + "id": null, + "sha256": "daf2f0cd4740f2d898eb9bbc7c0b2498a79342fc43a5b75aec9973ef7209dd05" + }, + { + "status": 409, + "code": "already_in_program_team", + "id": null, + "sha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0" + } + ] + }, + { + "program": 26, + "kind": "AC02 concurrent create", + "controllerPid": 19032, + "blockedWorkers": [ + { + "pid": 9344, + "pathToController": [ + 9344, + 4112, + 19032 + ] + }, + { + "pid": 4112, + "pathToController": [ + 4112, + 19032 + ] + } + ], + "origins": [ + "http://127.0.0.1:8877", + "http://127.0.0.1:8878" + ], + "results": [ + { + "status": 201, + "code": null, + "id": 40, + "sha256": "99f1a950fc4886f98918cbb5ab1240c5821e2a45a3dcee043fdf5f78c8469152" + }, + { + "status": 200, + "code": null, + "id": 40, + "sha256": "99f1a950fc4886f98918cbb5ab1240c5821e2a45a3dcee043fdf5f78c8469152" + } + ] + }, + { + "program": 26, + "kind": "AC08 accept different teams", + "controllerPid": 19032, + "blockedWorkers": [ + { + "pid": 32428, + "pathToController": [ + 32428, + 37072, + 19032 + ] + }, + { + "pid": 37072, + "pathToController": [ + 37072, + 19032 + ] + } + ], + "origins": [ + "http://127.0.0.1:8877", + "http://127.0.0.1:8878" + ], + "results": [ + { + "status": 200, + "code": null, + "id": null, + "sha256": "9c58c41280b44ca99ef632b237f5dd0bc32d98d6805dd2a0c92c82b6a6d79c7e" + }, + { + "status": 409, + "code": "already_in_program_team", + "id": null, + "sha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0" + } + ] + } + ], + "events": [ + { + "role": "manager", + "method": "GET", + "path": "/programs/25/management/solution-form/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "197c7c4194b429c09e6af61827870348cc8585dcad78a25b5c0730ec2422a110", + "requestValidated": false + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/25/management/solution-form/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "197c7c4194b429c09e6af61827870348cc8585dcad78a25b5c0730ec2422a110", + "requestValidated": false + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/25/management/solution-form/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "182016902e1014a41e51b4994cd8c017e6202397fac5edac5a46d59bcd9b2f67", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/25/solutions/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "799910d5fdfd0a1f9707ed90b8a76510009e94e66c34eaab3cfc48fd60489e71", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/25/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8878", + "responseSha256": "799910d5fdfd0a1f9707ed90b8a76510009e94e66c34eaab3cfc48fd60489e71", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/25/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "799910d5fdfd0a1f9707ed90b8a76510009e94e66c34eaab3cfc48fd60489e71", + "requestValidated": true + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/25/solution-context/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "372d78408c3d9df8c9a0acb7cdbebbfc5c4e0c9c582748e23e4f0bada2d21ab9", + "requestValidated": false + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/25/solutions/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "72724e55d31df3f6e6d0b4c6df25ef50441f5f85e1f302d1f711dae54c85081c", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/25/solutions/38/invitations/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "fcfc79a1b2fa697e7e37feac17972dfbc0bebdc66c6ddf6e769ca9537fbbd51e", + "requestValidated": true + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/25/solutions/39/invitations/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "af37b39aeb638dacac03a8970131080e2572d82a0dbf09231c8a977eedbd3556", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/47/accept/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "daf2f0cd4740f2d898eb9bbc7c0b2498a79342fc43a5b75aec9973ef7209dd05", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/48/accept/", + "status": 409, + "code": "already_in_program_team", + "origin": "http://127.0.0.1:8878", + "responseSha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0", + "requestValidated": true + }, + { + "role": "member", + "method": "GET", + "path": "/programs/25/solution-context/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "372d78408c3d9df8c9a0acb7cdbebbfc5c4e0c9c582748e23e4f0bada2d21ab9", + "requestValidated": false + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/48/accept/", + "status": 409, + "code": "already_in_program_team", + "origin": "http://127.0.0.1:8877", + "responseSha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/48/decline/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "c1aa849e1963bbde46728d64a0c58512f24fdf825d8e3586a5f70a46d084f433", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/25/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "bd13ab97eb052f6f82095bf7bf3287bf3eb716c24288ea8938bf27232b054f78", + "requestValidated": true + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/26/management/solution-form/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "4263c68db61aacac97fd7721319ba52c55f95688586b225b8ceef7357af9d4b7", + "requestValidated": false + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/26/management/solution-form/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "61474c4e64b9edebfee5990ff6c9d69fbe1d290ae2f6683be9d74d03d4bb2cd0", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/26/solutions/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "99f1a950fc4886f98918cbb5ab1240c5821e2a45a3dcee043fdf5f78c8469152", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/26/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8878", + "responseSha256": "99f1a950fc4886f98918cbb5ab1240c5821e2a45a3dcee043fdf5f78c8469152", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/26/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "99f1a950fc4886f98918cbb5ab1240c5821e2a45a3dcee043fdf5f78c8469152", + "requestValidated": true + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/26/solution-context/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "4a2309bcf841265215f777032f83aa2c6923106d4a5e49e9d2568afe15a4ac3b", + "requestValidated": false + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/26/solutions/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "64a8b511d3b994f1bc03c026ded0d3f436a26fd06d6650ffe733433de72a6c5c", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/26/solutions/40/invitations/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "13da3d198b598ff421c76479b0174c614659d2fb25ba384bcb298b1c975640a2", + "requestValidated": true + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/26/solutions/41/invitations/", + "status": 201, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "968b63274a58f723fbccecf2dba5361278ae4d2e36d18a28e4b3af4e3867eb14", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/49/accept/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "9c58c41280b44ca99ef632b237f5dd0bc32d98d6805dd2a0c92c82b6a6d79c7e", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/50/accept/", + "status": 409, + "code": "already_in_program_team", + "origin": "http://127.0.0.1:8878", + "responseSha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0", + "requestValidated": true + }, + { + "role": "member", + "method": "GET", + "path": "/programs/26/solution-context/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "4a2309bcf841265215f777032f83aa2c6923106d4a5e49e9d2568afe15a4ac3b", + "requestValidated": false + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/50/accept/", + "status": 409, + "code": "already_in_program_team", + "origin": "http://127.0.0.1:8877", + "responseSha256": "d39b1eeaca6c2a50d2aca98644d4c88ba0a2506853323989b0b5f317a07e0ac0", + "requestValidated": true + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/50/decline/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "a77e2328c0c63ff02a70a7dd282264cf0055fbc822a9bbeeb649a3437f5c5c66", + "requestValidated": true + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/26/solutions/", + "status": 200, + "code": null, + "origin": "http://127.0.0.1:8877", + "responseSha256": "c28bc92ba1d228ac35aa197dac943932ea39283bafac50bf4a4ef8dedb2ce1d4", + "requestValidated": true + } + ], + "setupHarnessFailure": { + "artifact": "http-attempt-1792917130399478400.json", + "reason": "Harness read revision from envelope instead of form.revision; no form write or solution creation ran before failure." + }, + "requestsValidated": 24, + "responsesValidated": 31, + "preexistingSelectedDomainUnchanged": true, + "baselineModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "projectCountUnchanged": true, + "limits": [ + "Fresh synthetic fixtures only; locmem mail, no external delivery.", + "Two independent API processes and two PostgreSQL wait chains observed before releasing each program lock.", + "HTTP acceptance/creation concurrency, not a load test or full UI acceptance." + ] +} diff --git a/docs/qa/program-solutions-membership-races-20261025/local-artifacts.json b/docs/qa/program-solutions-membership-races-20261025/local-artifacts.json new file mode 100644 index 00000000..3d7a6b0c --- /dev/null +++ b/docs/qa/program-solutions-membership-races-20261025/local-artifacts.json @@ -0,0 +1,125 @@ +{ + "rawDirectory": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-membership-races", + "artifacts": [ + { + "file": "browser-attempt-1792917329139.json", + "bytes": 18942, + "sha256": "21379ebddb69b54a6011fd9fc12926d9d0a77761c3d90752c7303f42808b4e9d" + }, + { + "file": "browser-attempt-1792917425290.json", + "bytes": 66735, + "sha256": "315ff6b7d388e6133885ed43381d7797092ffb6e71a428eec030a63b0d5818b7" + }, + { + "file": "browser-checks.json", + "bytes": 20098, + "sha256": "8345a62add9772d1c06dff5ff0beeffc4d6fe29b5c8ef9c7e5c3bddd41f6ff0b" + }, + { + "file": "browser-failure-1792917329143.png", + "bytes": 15230, + "sha256": "0605b73598d8125a86c1f6823ae9bdaef2964bd88ee02b379ce8d4f0aba51f27" + }, + { + "file": "case_championship-captain-team-390.png", + "bytes": 63385, + "sha256": "3f0395c2d63a55476447358a296705adadeae61fce09d7f641acac2dcca318e5" + }, + { + "file": "case_championship-member-team-390.png", + "bytes": 56692, + "sha256": "99187d6c96467e9dbadaacf4b0ee8201594f79f5b9dc99ff3b39cf43b285a84a" + }, + { + "file": "domain-and-contract-evidence.json", + "bytes": 2567, + "sha256": "169d129045928aad632f6d7e6d51e2d9b2d1acebd88f4eade99eb219fdd33824" + }, + { + "file": "hackathon-captain-team-390.png", + "bytes": 63381, + "sha256": "94daa6aa11c34918eb297f714d44dabc5d1a0fe5ee80c588e88d693010938fe2" + }, + { + "file": "hackathon-member-team-390.png", + "bytes": 54376, + "sha256": "689047f2eb4d149e94a215755a241b38bc30f7a03b69c6e6cf87a8a367f7d444" + }, + { + "file": "http-attempt-1792917130399478400.json", + "bytes": 1438, + "sha256": "d85d6e3ec82e7a80efb8e19da18ae33eae203b36495bdba7d75f16beef17cfc5" + }, + { + "file": "http-evidence.json", + "bytes": 16284, + "sha256": "8d3d222a32083c506ce6198f38ef3a78469b06dd77402f3b9da458a55d3da5b3" + }, + { + "file": "http-wire.json", + "bytes": 112112, + "sha256": "228f1a0b6af81beac8cbba43c8d305cf5422a6edda2908f91cd7a45315f5859c" + } + ], + "helpers": [ + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-race-worker.py", + "sha256": "eafa77a94d86c435a474350ab4e34249fe34ece981a152c3fceb4a6b99f3219e" + }, + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-race.py", + "sha256": "7d51615e140a8cdb71099d85a7a10b047b4391c496de0ed11289c41ab75c4a84" + }, + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-browser.cjs", + "sha256": "10cdf7cf671f0e0446d405dad3696286b2bc16d7bb4f891ca343399f341c2fe1" + }, + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-verify.py", + "sha256": "7da052fdca8ac196441c9c05ede0e645f4ba4bfe71a1e067d6fa1c0d4dae89b7" + }, + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-membership-package.py", + "sha256": "a614efac75f5406d6725a57e0151c6de650c2a3614e7fffa491db01e13b6eb0c" + }, + { + "path": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-expert-revision-spa.py", + "sha256": "b21c6cbb30700708b413a6d9b9aefcdf7c7c8fd89d6824cc86972fb95f7ee629" + } + ], + "sources": [ + { + "file": "partner_programs/solution_api.py", + "sha256": "1807b55255b544cd9bf8e514912331eeffab9fba889efcd29de44a33f3d16b01" + }, + { + "file": "partner_programs/solution_team.py", + "sha256": "38b836a54d19dc6c2b1f8f9b02db8ba5f573a44bba08ad6d0aa0e542abb14c17" + }, + { + "file": "partner_programs/solution_invitations.py", + "sha256": "886fbc7b120425a1fd101a03dfef4dbc3b690fbe54d8eec797db674feb97e64a" + }, + { + "file": "partner_programs/tests/test_solution_foundation.py", + "sha256": "9e7c4eb0b7e1b173c95a504f4b9768534e5c93a834591fa04f89294c9e26358c" + }, + { + "file": "partner_programs/tests/test_solution_invitations.py", + "sha256": "653bf43f619b1bb6df326386f5247735dc65dbaf8eeafab1bdeecaa7d49151e4" + }, + { + "file": "partner_programs/tests/test_solution_submission.py", + "sha256": "27ac6074dec2d6ca60d1d97b2643dc10d8427c4311dc570c08b7fe010eac09d0" + } + ], + "copiedJsonSemanticEquality": true, + "privateCredentialsIncluded": false, + "notes": [ + "4 screenshots retained locally; case captain and hackathon member at 390 were visually inspected.", + "Initial setup FAIL and two browser FAIL attempts preserved; no synthetic data was reset.", + "The second browser attempt completed 8 checks, then failed only a broad zero-write assertion. Curated report retains its error and 4 welcome acknowledgements.", + "No runtime code, contracts, migrations, deployments or real program switches changed in this checkpoint." + ] +} diff --git a/docs/qa/program-solutions-participant-20261025/README.md b/docs/qa/program-solutions-participant-20261025/README.md new file mode 100644 index 00000000..8a3fdb13 --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/README.md @@ -0,0 +1,22 @@ +# QA01 PART01: участник КЧ и хакатона + +Scope PASS от 25.10.2026; **общий QA01 и REL01 не завершены**. Требования/оставшиеся проверки — [матрица QA01](../../product/program-solutions-qa01.md). + +Runtime: backend 1724bca4c7cf7907955312d1f4c9e6ec44316fbd, frontend e57fb04ec93fdede611712014492a529cf9172b9, C00 1.12.0. Последующие docs commits не меняют эти runtime исходники. Local PostgreSQL 18, Django settings_ci, locmem mail, localhost storage; Angular SPA 8874 → HTTP API 8871. Это не production/deploy приемка. + +## Evidence + +- [browser-evidence.json](browser-evidence.json): реальные browser HTTP events, программа 7 КЧ/решение 6 и программа 8 хакатон/решение 7, checkpoints, 28 mutating HTTP requests (включая ожидаемые конфликты), JS errors=[]; один успешный submit на решение. Не содержит JWT/паролей. +- [attempt-1792901599060.json](attempt-1792901599060.json): raw completed-phase invocation с FAIL на финальной проверке, прочитавшей pending до завершения DELETE. Обе сдачи и успешные DELETE уже видны в событиях. Сохранен без удаления статуса FAIL. +- [run-state.json](run-state.json): фазы и IDs; отдельно записано удаление только прежнего draft=5 до прямого HTTP прогона. Submitted=6/7 сохранены. +- [domain-evidence.json](domain-evidence.json): итоговая проверка actual database snapshots/answers/members/invitations/files, 18 настоящих HTTP checks с C00 validation, forbidden writes не изменили доменные данные; Project count unchanged, самостоятельных Team models нет. Отзыв приглашения проверен отсутствием удаленной записи, а не выдуманным status=revoked. +- [build-provenance.json](build-provenance.json): только один generated JS chunk в копии development build получил локальные API/WebSocket origins. Product environment/source и обычный build не менялись. +- [local-artifacts.json](local-artifacts.json): SHA-256 12 screenshots и трех harness sources. Screenshots находятся в Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-participant; sources — C:/Users/evsee/AppData/Local/Temp/ с указанными именами. Fixtures/password manifest остается только в Temp, в Git не включен. + +Evidence состоит из завершенных реальных фаз и отдельной финальной проверки сохраненных submitted fixtures. Повторного заполнения или submit при resumed invocation нет. Первая попытка verifier ожидала несуществующий статус revoked; source/контракт используют DELETE. Исправлена только проверка, продуктовая логика не менялась. + +Полный основной browser путь выполнен на 390 px; состояния team/materials/readonly проверены на отсутствие overflow при 320/390/768/1440 px, desktop screenshot материалов отдельно. Mont загружен; screen reader, browser zoom и полный desktop путь не заявляются. Визуально просмотрены case_championship-materials-desktop, hackathon-submitted-materials-mobile и case_championship-submitted-team-mobile. Внешняя сеть блокировалась, default avatars отсутствуют; это ограничение local harness. + +Проверены: пустой draft без кейса/required answers, две вкладки с локальным вводом, actual program search + free role, принятие/отклонение через второй аккаунт, retry после version_conflict с сохранением выбора/роли, accepted/pending capacity, save 0/false/case, настоящий PDF upload/download bytes, cancel/confirm submit, immutable readonly snapshot, pending decline/revoke после сдачи, повторная сдача и прямые запреты прав. + +Не заявляются: ошибки submit/focus, stale form rebase, deadline/network negatives, все role bounds/edit приглашений, race verdict, manager/public UI, неконкурсный/legacy полный путь, production CDN/рассылки, общий QA01/REL01. Полный runtime CI отдельно PASS: [PostgreSQL 1652+39](https://github.com/PROCOLLAB-github/api/actions/runs/40159104055), [General Tests/Lint](https://github.com/PROCOLLAB-github/api/actions/runs/40159100597). CI logs проверены по Ran/OK, а не только статусу workflow. diff --git a/docs/qa/program-solutions-participant-20261025/attempt-1792901599060.json b/docs/qa/program-solutions-participant-20261025/attempt-1792901599060.json new file mode 100644 index 00000000..5826c384 --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/attempt-1792901599060.json @@ -0,0 +1,661 @@ +{ + "status": "FAIL", + "checkedAt": "2026-10-25T04:13:19.060Z", + "message": "AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:\n\n1 !== 0\n", + "events": [ + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/14/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/files/", + "status": 201 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/7/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/15/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/16/", + "status": 204 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/programs/8/acknowledge-welcome/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/17/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/8/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/18/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/19/", + "status": 204 + } + ], + "javascriptErrors": [], + "state": { + "prefix": "qa01-20261025-fa37de87", + "programs": { + "7": { + "solutionId": 6, + "template": "case_championship", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "submitted": true, + "complete": true + }, + "8": { + "solutionId": 7, + "template": "hackathon", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team" + ], + "submitted": true + } + }, + "fixtureReset": { + "removedDraftIds": [ + 5 + ], + "reason": "fresh direct HTTP run after harness origin/multipart setup" + } + } +} diff --git a/docs/qa/program-solutions-participant-20261025/browser-evidence.json b/docs/qa/program-solutions-participant-20261025/browser-evidence.json new file mode 100644 index 00000000..6d301c63 --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/browser-evidence.json @@ -0,0 +1,794 @@ +{ + "status": "PASS", + "checkedAt": "2026-10-25T04:18:46.802Z", + "prefix": "qa01-20261025-fa37de87", + "checks": [ + { + "program": 7, + "check": "resume immutable submitted fixture and verify final UI/state without repeat submit" + }, + { + "program": 8, + "check": "resume immutable submitted fixture and verify final UI/state without repeat submit" + } + ], + "programs": { + "7": { + "solutionId": 6, + "template": "case_championship", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "submitted": true, + "complete": true + }, + "8": { + "solutionId": 7, + "template": "hackathon", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "submitted": true, + "complete": true + } + }, + "events": [ + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/14/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/files/", + "status": 201 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/7/solutions/6/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/7/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/7/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/15/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/16/", + "status": 204 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/news/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/certificates/me/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/analytics-widget/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/programs/8/acknowledge-welcome/", + "status": 200 + }, + { + "role": "member", + "method": "POST", + "path": "/solution-invitations/17/accept/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "member", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 409 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/invitations/", + "status": 201 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/8/solutions/7/submit/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/news/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/certificates/me/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/programs/8/analytics-widget/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/8/acknowledge-welcome/", + "status": 200 + }, + { + "role": "pending_a", + "method": "POST", + "path": "/solution-invitations/18/decline/", + "status": 200 + }, + { + "role": "pending_a", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + }, + { + "role": "captain", + "dialog": "confirm", + "message": "Отозвать приглашение участнику София Сквозная?" + }, + { + "role": "captain", + "method": "DELETE", + "path": "/solution-invitations/19/", + "status": 204 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/7/solutions/6/invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solution-context/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/solution-invitations/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/", + "status": 200 + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/8/solutions/7/invitations/", + "status": 200 + } + ], + "javascriptErrors": [], + "widths": [ + 320, + 390, + 768, + 1440 + ], + "browser": "Headless Edge / Playwright", + "origin": "http://127.0.0.1:8874", + "api": "http://127.0.0.1:8871", + "buildProvenance": "C:/Users/evsee/AppData/Local/Temp/procollab-qa01-angular/qa-build-provenance.json", + "resumedFromCompletedPhases": { + "file": "attempt-1792901599060.json", + "reason": "previous real journeys completed both submits; final assertion raced DELETE response; retested final state with explicit response wait" + }, + "limitations": [ + "participant scope only; manager/public UI pending", + "full AA/screen reader and actual browser zoom not claimed", + "phase evidence and resumed final verification are distinguished; not claimed one uninterrupted final invocation", + "external network requests blocked; default remote avatars are absent in screenshots", + "mutatingHttpRequests includes expected conflicts; it is not a count of persisted mutations" + ], + "mutatingHttpRequests": 28 +} diff --git a/docs/qa/program-solutions-participant-20261025/build-provenance.json b/docs/qa/program-solutions-participant-20261025/build-provenance.json new file mode 100644 index 00000000..de45251e --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/build-provenance.json @@ -0,0 +1,13 @@ +{ + "source": "Z:/Документы/PROCOLLAB-dev/procollab-dev-program-solutions-fe01-20261024/dist/social_platform", + "target": "C:/Users/evsee/AppData/Local/Temp/procollab-qa01-angular", + "frontend": "e57fb04ec93fdede611712014492a529cf9172b9", + "changes": "only API/WebSocket development origins in copied generated JS", + "changed": [ + { + "file": "chunk-CHX6T2XY.js", + "before": "4707c125db5485fa8b5eeb9e30e23c6f90759d9f2daf86b56a5a0b332da287dd", + "after": "9952a60a44e2cf169099556f3b81c6866212b5aa80784d253b7b1a627defd7bd" + } + ] +} diff --git a/docs/qa/program-solutions-participant-20261025/domain-evidence.json b/docs/qa/program-solutions-participant-20261025/domain-evidence.json new file mode 100644 index 00000000..00f57588 --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/domain-evidence.json @@ -0,0 +1,176 @@ +{ + "status": "PASS", + "checkedAt": "2026-10-25T04:22:33.150928+00:00", + "programs": [ + { + "program_id": 7, + "solution_id": 6, + "template": "case_championship", + "accepted_members": 2, + "pending": 0, + "invitation_states": [ + "accepted", + "declined" + ], + "revoked_invitation_deleted": true, + "zeroFalseCaseAndSnapshot": true, + "files": [ + { + "field_id": 30, + "size": 597, + "sha256": "4722e145f386e32649b51fb43c5d7a09b559fd06c22ba2d89708a612d6f8c99e" + } + ] + }, + { + "program_id": 8, + "solution_id": 7, + "template": "hackathon", + "accepted_members": 2, + "pending": 0, + "invitation_states": [ + "accepted", + "declined" + ], + "revoked_invitation_deleted": true, + "zeroFalseCaseAndSnapshot": true, + "files": [] + } + ], + "httpChecks": [ + { + "path": "/programs/7/solutions/6/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/7/solutions/6/", + "role": "member", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/7/solution-context/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/7/solutions/6/", + "role": "pending_a", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/7/solutions/6/", + "role": "pending_b", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/7/solutions/6/", + "role": "anonymous", + "method": "GET", + "status": 401, + "code": "authentication_required" + }, + { + "path": "/programs/7/solutions/6/", + "role": "member", + "method": "PATCH", + "status": 403, + "code": "not_solution_captain" + }, + { + "path": "/programs/7/solutions/6/", + "role": "captain", + "method": "PATCH", + "status": 409, + "code": "team_frozen" + }, + { + "path": "/programs/7/solutions/6/submit/", + "role": "captain", + "method": "POST", + "status": 409, + "code": "already_submitted" + }, + { + "path": "/programs/8/solutions/7/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/8/solutions/7/", + "role": "member", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/8/solution-context/", + "role": "captain", + "method": "GET", + "status": 200, + "code": null + }, + { + "path": "/programs/8/solutions/7/", + "role": "pending_a", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/8/solutions/7/", + "role": "pending_b", + "method": "GET", + "status": 404, + "code": "not_found" + }, + { + "path": "/programs/8/solutions/7/", + "role": "anonymous", + "method": "GET", + "status": 401, + "code": "authentication_required" + }, + { + "path": "/programs/8/solutions/7/", + "role": "member", + "method": "PATCH", + "status": 403, + "code": "not_solution_captain" + }, + { + "path": "/programs/8/solutions/7/", + "role": "captain", + "method": "PATCH", + "status": 409, + "code": "team_frozen" + }, + { + "path": "/programs/8/solutions/7/submit/", + "role": "captain", + "method": "POST", + "status": 409, + "code": "already_submitted" + } + ], + "negativeVerificationDomainMutations": 0, + "projectCountUnchanged": true, + "standaloneTeamModels": [], + "limitations": [ + "actual browser journeys intentionally mutated synthetic data; this verifier did not", + "local storage file bytes verified, not production CDN", + "manager/public UI and full QA01 remain incomplete" + ] +} diff --git a/docs/qa/program-solutions-participant-20261025/local-artifacts.json b/docs/qa/program-solutions-participant-20261025/local-artifacts.json new file mode 100644 index 00000000..b7a1eefd --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/local-artifacts.json @@ -0,0 +1,67 @@ +{ + "localDirectory": "Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-participant", + "helpers": [ + { + "file": "procollab-qa01-participant-browser.cjs", + "sha256": "308563ef23f06dcade9b73b59d310e1be6be5305ca9673a1b32fce70896b1038" + }, + { + "file": "procollab-qa01-participant-verify.py", + "sha256": "b2a2c3658a82ea67c5bc17f95c89db52275da570698b6d5df866a57d94870ad8" + }, + { + "file": "procollab-qa01-local-build.cjs", + "sha256": "7d7b4d4c8e8cdd3e7ba6008b0bdad4f1551daf6c2a65768249ce4110d74d6c7a" + } + ], + "screenshots": [ + { + "file": "case_championship-confirmation-mobile.png", + "sha256": "9ae2f9b47c39105fc23897ba0eff61934bf33b06c8a70a1c05975057a71a687f" + }, + { + "file": "case_championship-materials-desktop.png", + "sha256": "258d58f4be62fdc9e65b23142f4d71172d199469f20a7dd8a1b74c4994ec1731" + }, + { + "file": "case_championship-materials-mobile.png", + "sha256": "1cdd076dc423d273a16daf59c00aea46128c3f283e0bd3a413b232b9b7a37e83" + }, + { + "file": "case_championship-submitted-materials-mobile.png", + "sha256": "25e6d6550ce189afe29255fd4e057a85598d53c2b28ad38b40e5e6963898d18f" + }, + { + "file": "case_championship-submitted-team-mobile.png", + "sha256": "e74d1e4dee34078fa2bad7ba1031a8e4bd7bfd5330d8ad1ae63c0cbff68ae2ef" + }, + { + "file": "case_championship-team-mobile.png", + "sha256": "4d10813040fdef13d2397123fa7d2c37a675c6d39fcb304d32302b95c5941a1d" + }, + { + "file": "hackathon-confirmation-mobile.png", + "sha256": "03fa0e508aa5d68cc21429ce96dd527366ae4bac59ff9bb07ee03e52ad0f350b" + }, + { + "file": "hackathon-materials-desktop.png", + "sha256": "acfdbd9a288219406a6c3198e1993e9b677b140b01144a2691cdbadf61eae948" + }, + { + "file": "hackathon-materials-mobile.png", + "sha256": "d2a77a47c50907daa1f9469aac6059131f11712cae5e718cfa00b8511ec8d234" + }, + { + "file": "hackathon-submitted-materials-mobile.png", + "sha256": "cd6351263b1ebfebfaef2ac387b89cd8c419d6c89ec97fc08426edc2e9bb4269" + }, + { + "file": "hackathon-submitted-team-mobile.png", + "sha256": "37a205fa964ee7f6082cb936e82ac5578fa87c9f02973419927e4c2607f1a0cc" + }, + { + "file": "hackathon-team-mobile.png", + "sha256": "c2fbba39f4f0bd0a70fd3573fec334e0699ad33cbb36b1f52f2a10d206b8eb22" + } + ] +} diff --git a/docs/qa/program-solutions-participant-20261025/run-state.json b/docs/qa/program-solutions-participant-20261025/run-state.json new file mode 100644 index 00000000..da47b18f --- /dev/null +++ b/docs/qa/program-solutions-participant-20261025/run-state.json @@ -0,0 +1,39 @@ +{ + "prefix": "qa01-20261025-fa37de87", + "programs": { + "7": { + "solutionId": 6, + "template": "case_championship", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "submitted": true, + "complete": true + }, + "8": { + "solutionId": 7, + "template": "hackathon", + "checks": [ + "empty draft", + "real invitation + accepted inbox + two pending + capacity", + "0/false/case retained across tabs and saved", + "cancel then one submit POST", + "snapshot and frozen materials/team", + "pending decline/revoke after frozen submission" + ], + "submitted": true, + "complete": true + } + }, + "fixtureReset": { + "removedDraftIds": [ + 5 + ], + "reason": "fresh direct HTTP run after harness origin/multipart setup" + } +} diff --git a/docs/qa/program-solutions-registration-removal-20261025/README.md b/docs/qa/program-solutions-registration-removal-20261025/README.md new file mode 100644 index 00000000..331b9e08 --- /dev/null +++ b/docs/qa/program-solutions-registration-removal-20261025/README.md @@ -0,0 +1,26 @@ + + +# QA01: удаление регистрации с сохранением решения + +25 октября 2026 года. BE08C2: signed удаление регистраций, независимый аудит и retention работ. Это checkpoint первого выпуска; весь BE01–BE09/FE01–FE08/QA01/REL01 еще не завершен. + +Product refs: [backend abfb7c7e](https://github.com/PROCOLLAB-github/api/commit/abfb7c7e8584365cfe5846618cc03af719cc6fad), [frontend 7e5bf118](https://github.com/PROCOLLAB-github/procollab/commit/7e5bf1183bb15f3d99b030854df8b6bca11c4cb7). [C00 1.20.0](../../contracts/program-solutions-v1/README.md):156 схем/59 операций/297 независимых сценариев. Все55 прежних операций и272 сценария сохранены; две схемы истории расширены совместимо. Три shared файла byte-identical. [Scope BE08C2](https://github.com/PROCOLLAB-github/api/blob/abfb7c7e8584365cfe5846618cc03af719cc6fad/docs/product/program-solutions-be08-registration-removal.md). + +## Проверенный объем + +- Native PostgreSQL:417 solution tests/OK без skips, включая26 новых (23 функциональных,2 наблюдаемых гонки,1 historical migration). Отдельно152 legacy tests/OK по прежним12 labels Project/team/invites/management/case lifecycle. Эти числа не складываются с предыдущими22/416 прогоном. +- Frontend:2948 tests/484 files, strict types, development build и scoped ESLint PASS. В том числе135 case/member/archive/history/registration wire tests (33/22/27/22/31). Locked Prettier3.8.3 до report проверил2037 файлов; финальная проверка с report:2042 файлов PASS. +- Actual HTTP:74 schema-valid responses и44 valid requests, включая401/403/404/409, подпись, replay, score drift, display-name drift, pending-only, submitted member, captain bulk archive и регистрацию без решения. Набор включает первые68 ответов; завершенные mutations не повторялись. Финальные6 ответов сняты после restart API с финальным fingerprint. +- Local0038:23 модели,58 решений,4 архива,8 прежних case/member/archive events,4 Project и8 UserFile сохранены. SHA до/после `717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d`. Миграция не удаляла и не архивировала старые записи. + +Новые программы43/44/45: КЧ, хакатон, неконкурсная. Работы60/63 сняты после удаления регистраций их капитанов;63 остается draft без submit. В60 до архива отдельно отозван pending и исключен участник после сдачи: исходный/новый состав сохранен в истории, ответы и оценки string `0`/`False` остались. Назначения сохранены при исключении участника и сняты при архиве. Принятое приглашение сохранено, но доступа к материалам исключенному участнику не дает. Работы61/62 остаются draft с материалами и капитаном после удаления регистрации участника. Семь audit headers включают удаления без решения, manager/expert registration и сохраненные роли; шесть новых per-solution events. Все новые creation flags=False. Исходные23 модели совпадают с baseline и после HTTP; новый Project не создавался. Fixture IDs не относятся к реальным программам. + +Архивные составы и снимки не меняются при последующем удалении регистрации. Смешанные legacy links блокируют всю новую команду. Auth User, manager/expert, другие программы и Project handlers сохраняются. Original26 AC strings неизменны;23 PASS: AC01–17/20/22–26, AC18/19 OPEN, AC21 отдельно BE10. Дополнительные admin tests не подменяют исходные критерии. + +## Ограничения и воспроизводимость + +Сохранены ранний focused log (31 FE tests PASS), промежуточный416 и финальный417, первые68 и финальные74 HTTP-ответа. Ошибка расположения negative TypeScript directive была видна в terminal output и исправлена до финальных проверок. Первые31 FE focused tests прошли; type assertion исправлена до полного2948/types/build. Итоговые assertions не ослаблялись, skips на PostgreSQL отсутствуют. Raw logs/helpers находятся в `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-solution-registration-removal`; SHA перечислены в [evidence.json](evidence.json). [HTTP wire](http-wire.json) проверен до удаления signed proof strings; 25 значений скрыто только в Git-копии, Authorization/JWT/password не записывались. Private baseline/manifest остаются в TEMP; migration helper повторно не запускать. + +Owned API8877 остановлен; прежние8769/8871/8876 не перезапускались и не доказывают текущий product. Binary/CDN operations проверяются native regression с mock, новая production CDN/browser приемка этим checkpoint не заявляется. Новый organizer UI/confirmation/history и навигация архива следуют D02; D03 и конкурсные несданные черновики уже запрошены и остаются открыты. Manager/reminder/actions/results/public screens и полный QA01/REL01 остаются в первом выпуске. BE10 отдельно. + +Preceding C1 Linux PG40208633691 terminal success:39 locking +1783 full/OK; General40208630839 еще in progress на момент упаковки. Это результат предыдущего bf5fc5a, не текущего C2. Новый HEAD CI фиксируется после push отдельно.0038 → backend → frontend; после C2 события сохранять readers/guards C00 1.20 и аудит, schema-down недопустим. Merge/deploy/реальных переключений/автоматического переноса или удаления Project не было. diff --git a/docs/qa/program-solutions-registration-removal-20261025/contract-evidence.json b/docs/qa/program-solutions-registration-removal-20261025/contract-evidence.json new file mode 100644 index 00000000..1abb07af --- /dev/null +++ b/docs/qa/program-solutions-registration-removal-20261025/contract-evidence.json @@ -0,0 +1,18 @@ +{ + "status": "PASS", + "contract": "1.20.0", + "schemas": 156, + "operations": 59, + "scenarios": 297, + "oldSchemasUnchanged": 143, + "oldSchemasCompatiblyExtended": ["SolutionAdminChange", "SolutionArchiveAdminChange"], + "oldOperationsUnchanged": 55, + "oldScenariosUnchanged": 272, + "newScenarios": 25, + "sharedFileHashes": { + "openapi.json": "1f1350d730b66891e0c019bff92b97c0129f62ae6f3d261bab2e27a7953b7191", + "examples.json": "69cdb43626d6d89d8f7716d2d6f5777ed6f147496c72046dd364503fbf18a93f", + "README.md": "d6505ba0c6035e0486f66e2911032ff5ec430ea6dab2d8283abfc5284a2ce1ab" + }, + "originalAcceptanceRowsUnchanged": 26 +} diff --git a/docs/qa/program-solutions-registration-removal-20261025/evidence.json b/docs/qa/program-solutions-registration-removal-20261025/evidence.json new file mode 100644 index 00000000..2fb654d9 --- /dev/null +++ b/docs/qa/program-solutions-registration-removal-20261025/evidence.json @@ -0,0 +1,218 @@ +{ + "status": "PASS", + "classification": "BE08C2 backend + FE API checkpoint; first release incomplete", + "backendProduct": "abfb7c7e8584365cfe5846618cc03af719cc6fad", + "frontendProduct": "7e5bf1183bb15f3d99b030854df8b6bca11c4cb7", + "contract": { + "status": "PASS", + "contract": "1.20.0", + "schemas": 156, + "operations": 59, + "scenarios": 297, + "oldSchemasUnchanged": 143, + "oldSchemasCompatiblyExtended": ["SolutionAdminChange", "SolutionArchiveAdminChange"], + "oldOperationsUnchanged": 55, + "oldScenariosUnchanged": 272, + "newScenarios": 25, + "sharedFileHashes": { + "openapi.json": "1f1350d730b66891e0c019bff92b97c0129f62ae6f3d261bab2e27a7953b7191", + "examples.json": "69cdb43626d6d89d8f7716d2d6f5777ed6f147496c72046dd364503fbf18a93f", + "README.md": "d6505ba0c6035e0486f66e2911032ff5ec430ea6dab2d8283abfc5284a2ce1ab" + }, + "originalAcceptanceRowsUnchanged": 26 + }, + "http": { + "status": "PASS", + "contract": "1.20.0", + "responses": 74, + "validRequests": 44, + "programs": [ + { + "id": 43, + "key": "case", + "solutions": [60, 63], + "audits": [1, 2, 3, 4] + }, + { + "id": 44, + "key": "hack", + "solutions": [61], + "audits": [5, 7] + }, + { + "id": 45, + "key": "regular", + "solutions": [62], + "audits": [6] + } + ], + "oldModelsUnchanged": 23, + "hashBefore": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "hashAfter": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "existingProjectsPreserved": 4, + "existingArchivesPreserved": 4, + "existingHistoryPreserved": 8, + "newProjects": 0, + "localOnly": true, + "creationFlagsFalse": true + }, + "migration": { + "status": "PASS", + "migration": "0038_solution_registration_removal_audit", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "hashAfter": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "rowCounts": { + "partner_programs.PartnerProgram": 40, + "partner_programs.PartnerProgramField": 281, + "partner_programs.PartnerProgramUserProfile": 116, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 58, + "partner_programs.ProgramSolutionAnswer": 273, + "partner_programs.ProgramSolutionMember": 80, + "partner_programs.SolutionInvitation": 38, + "project_rates.SolutionScore": 34, + "project_rates.SolutionExpertAssignment": 20, + "project_rates.Criteria": 69, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 27, + "partner_programs.PartnerProgram_managers": 44, + "partner_programs.SolutionAdministrativeChange": 8 + }, + "existingAdminEventsPreserved": 8, + "existingArchivedSolutionsPreserved": 4, + "existingRowsDeletedOrArchived": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true + }, + "tests": { + "backendSolutions": 417, + "newRegistrationTests": 26, + "newFunctionalTests": 23, + "newObservedPGRaces": 2, + "newHistoricalMigrationTests": 1, + "legacyRegression": 152, + "frontend": 2948, + "frontendFiles": 484, + "strictTypes": "PASS", + "developmentBuild": "PASS", + "administrativeWireTests": 135, + "wireBreakdown": { + "case": 33, + "member": 22, + "archive": 27, + "history": 22, + "registration": 31 + }, + "lockedPrettierBeforeReports": { + "version": "3.8.3", + "files": 2037, + "status": "PASS" + }, + "lockedPrettierWithReports": { + "version": "3.8.3", + "files": 2042, + "status": "PASS" + } + }, + "sourceHashesNormalizedLF": { + "backend": { + "docs/contracts/program-solutions-v1/README.md": "d6505ba0c6035e0486f66e2911032ff5ec430ea6dab2d8283abfc5284a2ce1ab", + "docs/contracts/program-solutions-v1/examples.json": "d2c5b8a5d81d6376150523dd8fd2e4da70801820e92ec3b1c815ec720ae41b9f", + "docs/contracts/program-solutions-v1/openapi.json": "5d417fac5d9dcc56a96c26176bfdb68c3e8c30f9cfb85084d285be0fdc70a397", + "docs/product/program-solutions-be08-admin-exceptions.md": "530041af9c96734cc6f88c57ec3800df448900263c6c5f19ef1d108b7b503c27", + "docs/product/program-solutions-be08-registration-removal.md": "59a9b188cf28d77b3e49d1bda94c02c988dd71c328c483e198af383789da6a1a", + "docs/product/program-solutions-qa01.md": "f6f6aa876478b0e6d7c1637ef8a69043e8ace402e13130e13b9325cbef3681be", + "docs/product/program-solutions-rel01.md": "1a9b3a7a132fa575484a529ecf03a8251ae63560864152763fe68363bf008cf7", + "partner_programs/migrations/0038_solution_registration_removal_audit.py": "a55958f8662cfb5b3d3fb55cfe5ee76d0ee6ff5d83595e206dcc84ac77a2017e", + "partner_programs/services/solution_archive_actions.py": "7571e0dff1b6bbcabccc69336c23e9de377ff6d31e9ed094542499991bebd7e5", + "partner_programs/services/solution_member_actions.py": "573679c07be01575d355f2556c1880ab3ebc0289c757b1b4910070cfed3ac1b3", + "partner_programs/services/solution_registration_actions.py": "799756778ac9b50c20ad106eb3863a3d455ed372b42e35be7f5bc667ba1fddbd", + "partner_programs/solution_admin.py": "e0bcacb374c18a4e495f5b4425e471483ec593f24844470f590bd4d5d00cf093", + "partner_programs/solution_history_api.py": "7417e9ede5889cfbac2ddc7be0167445b522d56a1fdba290286f624d5014c95f", + "partner_programs/solution_lifecycle_signals.py": "ab586c9344d83296679fabb36b7cd67ad048eecb8d8b6e6ba7abd8a548a6ce34", + "partner_programs/solution_models.py": "af30b98eb2bda77bea9ffbc5c4e33e2f2479d9c2bc9e6c301013416efc762803", + "partner_programs/solution_registration_action_api.py": "809d706802f2b090a4e871de284ea5fb813af0dfee9ab9c5faa07650efcf9e10", + "partner_programs/tests/test_solution_registration_actions.py": "d3c4acab25d915e2c5fa873ae7c4d7f1f9e528e96ef471efc9dadac88057099c", + "partner_programs/urls.py": "a5ea50390d1de3a76f56a456547cbb17504408eb2477f35af327b622f8dd851d" + }, + "frontend": { + "docs/contracts/program-solutions-v1/README.md": "d6505ba0c6035e0486f66e2911032ff5ec430ea6dab2d8283abfc5284a2ce1ab", + "docs/contracts/program-solutions-v1/examples.json": "d2c5b8a5d81d6376150523dd8fd2e4da70801820e92ec3b1c815ec720ae41b9f", + "docs/contracts/program-solutions-v1/openapi.json": "5d417fac5d9dcc56a96c26176bfdb68c3e8c30f9cfb85084d285be0fdc70a397", + "projects/social_platform/src/app/api/solution/solution-registration-removal-contract.spec.ts": "ac8391f523917d264db8e2132d536091c71fb31ab53e2299468aa45d1a097780", + "projects/social_platform/src/app/api/solution/use-cases/solution-registration-removal.use-cases.ts": "a74bc7bc6d772bd6911e4fd3d621f7815bed0c3faa08ec4c0befe8692336026b", + "projects/social_platform/src/app/domain/solution/ports/solution.repository.port.ts": "1149ef61a7b7df36399ac0dc8e5234543e76d8154dfd470342a733f6ede01ba2", + "projects/social_platform/src/app/domain/solution/solution-contract.fixture.ts": "0e2fa788fa51b2b4dce80100ce7d85c4c8696f3281b4ef67e040f030b0b1d728", + "projects/social_platform/src/app/domain/solution/solution-contract.model.ts": "15f682687c0a437c2700da3dda35978bdca5b48f8c45eb94b42766e6d2cce801", + "projects/social_platform/src/app/infrastructure/adapters/solution/solution-http.adapter.ts": "83e34263a224e20ffdcff393df3e85da85aa39633119d1c3e073617e1a8bb742", + "projects/social_platform/src/app/infrastructure/repository/solution/solution.repository.ts": "698dfaddddb8e40adf862e9083b9bfc54e13648d9bd81bd4f7ffba781dfc61bf", + "tools/verify-solution-contract-types.ts": "0dfa81db6386ff479fad5c42351cd0f9d4a1f5345b449840c098bfc9027667d1" + } + }, + "rawArtifacts": { + "contract-evidence.json": "afc0dd26e3e6b6ca06975c451a056b74b103623c4d65708da7ec4695ebbeaee8", + "http-evidence.json": "dd2d4624589c2b601c20d699e82f89ca29999ecd61cfab26ef7e5b7c349c8678", + "http-wire.json": "c08bb97c9e77823ccf5a9d4f46fff4893a07f2c1d3c1295a669b48c587cb8f83", + "migration-evidence.json": "baa84cade150fb0ec2a635aa133ddacfe5d67de9ec9090eb57321a1e98571179", + "procollab-registration-contract-evidence.py": "4b982019e2c48cf3e079f7b0f61ef6dd1cb257c03782a43f38751b6e429d304b", + "procollab-registration-contract.py": "f80a5c3baa380fc31f4f7c9254fe716170160e4cbdea4a53b7f7cec82b339df3", + "procollab-registration-fe.py": "2cab10b1e7ec8f028d4a207f381f9607b164c91b1a91402ceec4702ccf92e32a", + "procollab-registration-migration-order.py": "b823b89c361b8c978bd8d68c8ec5ea66c6df3898265baff077e263e6813c2284", + "procollab-registration-product-docs.py": "ee50d34a8a067073e1218fd4b5364033e3717298b658412bb69857fce53ed264", + "procollab-registration-refactor.py": "25a038886d297dfa36c6615a761436979155799f2c4de00d7ff07ab0f0638c57", + "procollab-solution-registration-fe-build.log": "1044af952d79b8eeedbbfea14dc214844e935ba5dc7d01184ebb08f5c3d39e58", + "procollab-solution-registration-fe-focused.log": "94bab194d822f8e2b3023c62168236733f79fa264ee02d7652eb63ee9ad21d12", + "procollab-solution-registration-fe-suite.log": "4c39cd4c6b166675923dee94ff891e916497aa3ee626c00d5320b42ccf91b7c3", + "procollab-solution-registration-focused.log": "6bbcccaa7eb9fa6940805292ece4c8ae1e79dbd18bc75e19c3e826ddd701b453", + "procollab-solution-registration-http-final.log": "3e7c93f2d26256e4c0734717be2bf7f6851e3898a6ce6a66bbaabfa2986dc366", + "procollab-solution-registration-http.log": "ce990abb70cb4ee201b634ef702ccddf5d097c94a44a3717540f7511e6800e04", + "procollab-solution-registration-http.py": "02ee0f6fd48ec257ce3fdc2120a08bf64cbb2b1ae3ee166283e88c5d9ba2dacb", + "procollab-solution-registration-legacy.log": "e7dd155a56641e2bf7af21983729db35195b690aecdfef8c05be9009b9d73abb", + "procollab-solution-registration-migrate.py": "a243dea3bfa4f5dc436a68b7e096cd609c03558afadb3502f71746fb21f61d67", + "procollab-solution-registration-preceding-pg-ci.log": "b825f20caaa349eab8cde15cbae34bb23bf792c471d8f538f07d07ddceb96691", + "procollab-solution-registration-server.py": "2b2edeffed8c144f469726a59db01561f309ddb0c792927cc44a4948a0aa1e27", + "procollab-solution-registration-suite-final.log": "c00acfd82f3f812a89a2cf4b7e70267920073a5d7e15effdbc5ee91237f3918e", + "procollab-solution-registration-suite.log": "87b83b32a56774d3856415476dfb035fd7bb0d1305ab98288c75835208e7ac01", + "procollab-solution-registration-final-format.json": "7b9282c7f10eef7761182843bc43761099da5080beb561f7299ab1fa3954b9b7" + }, + "retainedAttempts": [ + "First native focused 22/OK; later final new test group26 is included in417.", + "First FE focused31 passed; negative TS assertion directive moved after Prettier before final full2948/types/build.", + "Initial full416 passed; final417 repeated after binding display identity in no-solution preview.", + "Initial68 HTTP responses retained; final74 includes an actual stale identity confirmation on the final restarted API." + ], + "publication": "Program-only storage remains unchanged; competitive unsent drafts eligibility pending.", + "qa": { + "originalAcceptanceRows": 26, + "passCount": 23, + "passAC": "01-17,20,22-26", + "openAC": ["18", "19"], + "separateBE10": "21" + }, + "limitations": [ + "No new organizer/public UI/browser/mobile acceptance in this checkpoint.", + "D02/D03 approval, manager/reminder/actions/results/public screens, archive navigation, competitive draft eligibility and full QA01/REL01 remain in first release.", + "No merge/deploy/real-program switch/Project migration or deletion.", + "Current HEAD Linux CI is not yet claimed; preceding C1 PG run40208633691:39 locking +1783 full/OK. General40208630839 still in progress at packaging." + ], + "helpers": { + "ownedApiStopped": true, + "releasedPort": 8877, + "oldServersUnchanged": { + "8769": 20920, + "8871": 19092, + "8876": 33332 + } + }, + "redactedSignedProofOccurrences": 25 +} diff --git a/docs/qa/program-solutions-registration-removal-20261025/http-wire.json b/docs/qa/program-solutions-registration-removal-20261025/http-wire.json new file mode 100644 index 00000000..64938619 --- /dev/null +++ b/docs/qa/program-solutions-registration-removal-20261025/http-wire.json @@ -0,0 +1,6339 @@ +[ + { + "actor": "captain", + "method": "POST", + "path": "/programs/43/solutions/", + "status": 201, + "request": { + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "response": { + "id": 60, + "program_id": 43, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:51.346683Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/43/solutions/60/", + "status": 200, + "request": null, + "response": { + "id": 60, + "program_id": 43, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:51.346683Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/43/solutions/60/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 20, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 64, + "version": 1, + "program": { + "id": 43, + "name": "Удаление регистраций case qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T16:20:51.746859Z", + "updated_at": "2026-10-25T16:20:51.746859Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/64/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 64, + "version": 2, + "program": { + "id": 43, + "name": "Удаление регистраций case qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T16:20:51.746859Z", + "updated_at": "2026-10-25T16:20:51.929766Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/43/solutions/60/", + "status": 200, + "request": null, + "response": { + "id": 60, + "program_id": 43, + "version": 3, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:51.940304Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/43/solutions/60/invitations/", + "status": 201, + "request": { + "expected_solution_version": 3, + "recipient_user_id": 21, + "role": "Дизайнер" + }, + "response": { + "invitation": { + "id": 65, + "version": 1, + "program": { + "id": 43, + "name": "Удаление регистраций case qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 21, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Дизайнер", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T16:20:52.312212Z", + "updated_at": "2026-10-25T16:20:52.312212Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 4 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/43/solutions/60/", + "status": 200, + "request": null, + "response": { + "id": 60, + "program_id": 43, + "version": 4, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:52.319311Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/43/solutions/60/", + "status": 200, + "request": { + "expected_version": 4, + "expected_form_revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "answers": [ + { + "field_id": 293, + "value": "Город" + }, + { + "field_id": 294, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 295, + "value": 0 + }, + { + "field_id": 296, + "value": false + } + ] + }, + "response": { + "id": 60, + "program_id": 43, + "version": 5, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:52.718844Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 293, + "value": "Город" + }, + { + "field_id": 294, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 295, + "value": 0 + }, + { + "field_id": 296, + "value": false + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/43/solutions/60/submit/", + "status": 200, + "request": { + "expected_version": 5, + "expected_form_revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992" + }, + "response": { + "id": 60, + "program_id": 43, + "version": 6, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:53.019840Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 1, + "reserved_count": 3, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": true, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 293, + "value": "Город" + }, + { + "field_id": 294, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 295, + "value": 0 + }, + { + "field_id": 296, + "value": false + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/44/solutions/", + "status": 201, + "request": { + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "response": { + "id": 61, + "program_id": 44, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:53.173180Z", + "updated_at": "2026-10-25T16:20:53.173180Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:53.178841Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 44, + "revision": "0f42c860c99957bf2cf0f5d0f71bb387e1e332e6cc40031acf11786e7e91f3d4", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 297, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 298, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 299, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 300, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 301, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 297 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 298 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 299 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 300 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 301 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/44/solutions/61/", + "status": 200, + "request": null, + "response": { + "id": 61, + "program_id": 44, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:53.173180Z", + "updated_at": "2026-10-25T16:20:53.173180Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:53.178841Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 44, + "revision": "0f42c860c99957bf2cf0f5d0f71bb387e1e332e6cc40031acf11786e7e91f3d4", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 297, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 298, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 299, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 300, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 301, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 297 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 298 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 299 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 300 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 301 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/44/solutions/61/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 20, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 66, + "version": 1, + "program": { + "id": 44, + "name": "Удаление регистраций hack qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T16:20:53.589134Z", + "updated_at": "2026-10-25T16:20:53.589134Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/66/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 66, + "version": 2, + "program": { + "id": 44, + "name": "Удаление регистраций hack qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T16:20:53.589134Z", + "updated_at": "2026-10-25T16:20:53.733409Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/44/solutions/61/", + "status": 200, + "request": null, + "response": { + "id": 61, + "program_id": 44, + "version": 3, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:53.173180Z", + "updated_at": "2026-10-25T16:20:53.741105Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:53.178841Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:53.725029Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 44, + "revision": "0f42c860c99957bf2cf0f5d0f71bb387e1e332e6cc40031acf11786e7e91f3d4", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 297, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 298, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 299, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 300, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 301, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 297 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 298 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 299 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 300 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 301 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/44/solutions/61/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "0f42c860c99957bf2cf0f5d0f71bb387e1e332e6cc40031acf11786e7e91f3d4", + "answers": [ + { + "field_id": 297, + "value": "Город" + }, + { + "field_id": 298, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 299, + "value": 0 + }, + { + "field_id": 300, + "value": false + }, + { + "field_id": 301, + "value": "https://example.com/registration-code/qa01reg-20261025-c45626d0" + } + ] + }, + "response": { + "id": 61, + "program_id": 44, + "version": 4, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:53.173180Z", + "updated_at": "2026-10-25T16:20:54.066386Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:53.178841Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:53.725029Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 44, + "revision": "0f42c860c99957bf2cf0f5d0f71bb387e1e332e6cc40031acf11786e7e91f3d4", + "template": "hackathon", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 297, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 298, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 299, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 300, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 301, + "type": "url", + "purpose": "repository", + "label": "Репозиторий", + "help_text": "", + "required": true, + "position": 4, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 297, + "value": "Город" + }, + { + "field_id": 298, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 299, + "value": 0 + }, + { + "field_id": 300, + "value": false + }, + { + "field_id": 301, + "value": "https://example.com/registration-code/qa01reg-20261025-c45626d0" + } + ], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": true, + "issues": [] + } + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/45/solutions/", + "status": 201, + "request": { + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "response": { + "id": 62, + "program_id": 45, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:54.245085Z", + "updated_at": "2026-10-25T16:20:54.245085Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:54.252779Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 45, + "revision": "c89c925124ce46e44f6f2e9809dcbeb92a75003cccb7362e5ab04ffd112ec14f", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 302, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 303, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 304, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 305, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/45/solutions/62/", + "status": 200, + "request": null, + "response": { + "id": 62, + "program_id": 45, + "version": 1, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:54.245085Z", + "updated_at": "2026-10-25T16:20:54.245085Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:54.252779Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 45, + "revision": "c89c925124ce46e44f6f2e9809dcbeb92a75003cccb7362e5ab04ffd112ec14f", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 302, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 303, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 304, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 305, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "POST", + "path": "/programs/45/solutions/62/invitations/", + "status": 201, + "request": { + "expected_solution_version": 1, + "recipient_user_id": 20, + "role": "Аналитик" + }, + "response": { + "invitation": { + "id": 67, + "version": 1, + "program": { + "id": 45, + "name": "Удаление регистраций regular qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "pending", + "created_at": "2026-10-25T16:20:54.531130Z", + "updated_at": "2026-10-25T16:20:54.531130Z", + "permissions": { + "can_edit": true, + "can_revoke": true, + "can_accept": false, + "can_decline": false + } + }, + "solution_version": 2 + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "POST", + "path": "/solution-invitations/67/accept/", + "status": 200, + "request": { + "expected_version": 1 + }, + "response": { + "invitation": { + "id": 67, + "version": 2, + "program": { + "id": 45, + "name": "Удаление регистраций regular qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T16:20:54.531130Z", + "updated_at": "2026-10-25T16:20:54.708310Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "application": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 3 + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/45/solutions/62/", + "status": 200, + "request": null, + "response": { + "id": 62, + "program_id": 45, + "version": 3, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:54.245085Z", + "updated_at": "2026-10-25T16:20:54.716052Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:54.252779Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:54.696345Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 45, + "revision": "c89c925124ce46e44f6f2e9809dcbeb92a75003cccb7362e5ab04ffd112ec14f", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 302, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 303, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 304, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 305, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "captain", + "method": "PATCH", + "path": "/programs/45/solutions/62/", + "status": 200, + "request": { + "expected_version": 3, + "expected_form_revision": "c89c925124ce46e44f6f2e9809dcbeb92a75003cccb7362e5ab04ffd112ec14f", + "answers": [ + { + "field_id": 302, + "value": "Город" + }, + { + "field_id": 303, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 304, + "value": 0 + }, + { + "field_id": 305, + "value": false + } + ] + }, + "response": { + "id": 62, + "program_id": 45, + "version": 4, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:54.245085Z", + "updated_at": "2026-10-25T16:20:55.061769Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:54.252779Z", + "is_captain": true + }, + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:54.696345Z", + "is_captain": false + } + ], + "accepted_count": 2, + "pending_count": 0, + "reserved_count": 2, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 45, + "revision": "c89c925124ce46e44f6f2e9809dcbeb92a75003cccb7362e5ab04ffd112ec14f", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 302, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 303, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 304, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 305, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [ + { + "field_id": 302, + "value": "Город" + }, + { + "field_id": 303, + "value": "Материалы qa01reg-20261025-c45626d0" + }, + { + "field_id": 304, + "value": 0 + }, + { + "field_id": 305, + "value": false + } + ], + "submission": { + "required": false, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": false, + "can_leave": false + }, + "readiness": null + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "POST", + "path": "/programs/43/solutions/", + "status": 201, + "request": { + "name": "Работа extra qa01reg-20261025-c45626d0" + }, + "response": { + "id": 63, + "program_id": 43, + "version": 1, + "name": "Работа extra qa01reg-20261025-c45626d0", + "captain_id": 22, + "created_at": "2026-10-25T16:20:55.355057Z", + "updated_at": "2026-10-25T16:20:55.355057Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 22, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:55.362746Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/43/solutions/63/", + "status": 200, + "request": null, + "response": { + "id": 63, + "program_id": 43, + "version": 1, + "name": "Работа extra qa01reg-20261025-c45626d0", + "captain_id": 22, + "created_at": "2026-10-25T16:20:55.355057Z", + "updated_at": "2026-10-25T16:20:55.355057Z", + "participation": { + "status": "active", + "archived_at": null, + "reason": null + }, + "team": { + "members": [ + { + "user": { + "id": 22, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": null, + "specialization": null, + "joined_at": "2026-10-25T16:20:55.362746Z", + "is_captain": true + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": false + }, + "form": { + "program_id": 43, + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "is_configured": true, + "is_snapshot": false, + "fields": [ + { + "id": 293, + "type": "select", + "purpose": "case", + "label": "Кейс", + "help_text": "", + "required": true, + "position": 0, + "options": ["Город", "Образование"], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "purpose": "other", + "label": "Материалы", + "help_text": "", + "required": true, + "position": 1, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "purpose": "other", + "label": "Число", + "help_text": "", + "required": true, + "position": 2, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "purpose": "other", + "label": "Флаг", + "help_text": "", + "required": true, + "position": 3, + "options": [], + "validation": { + "min_length": null, + "max_length": null, + "minimum": null, + "maximum": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ] + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": true, + "deadline": "2026-10-26T16:20:51.000466Z", + "submitted_at": null + }, + "permissions": { + "can_edit": true, + "can_manage_team": true, + "can_invite": true, + "can_submit": true, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "answers", + "code": "case_required", + "detail": "Выберите кейс или задачу.", + "field_id": 293 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 294 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 295 + }, + { + "path": "answers", + "code": "required", + "detail": "Заполните обязательное поле.", + "field_id": 296 + } + ] + } + }, + "cacheControl": null + }, + { + "actor": null, + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 401, + "request": { + "registration_ids": [124] + }, + "response": { + "code": "authentication_required", + "detail": "Authentication credentials were not provided.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 403, + "request": { + "registration_ids": [124] + }, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 404, + "request": { + "registration_ids": [131] + }, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/34/management/registrations/remove/preview/", + "status": 409, + "request": { + "registration_ids": [124] + }, + "response": { + "code": "solution_mode_unavailable", + "detail": "Программа использует заявки на основе проектов.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [124] + }, + "response": { + "registrations": [ + { + "registration_id": 124, + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 6, + "is_submitted": true, + "effect": "remove_members", + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [124], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [125] + }, + "response": { + "registrations": [ + { + "registration_id": 125, + "user": { + "id": 21, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 6, + "is_submitted": true, + "effect": "revoke_invitations", + "removed_members": [], + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 1, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [125], + "preview_token": "" + }, + "response": { + "audit_id": 1, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 7, + "change_id": 9, + "action": "remove_registrations" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [125], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/1/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 1, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.086574Z", + "registrations": [ + { + "user": { + "id": 21, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 125 + } + ], + "deleted_count": 1 + }, + "changes": [ + { + "id": 9, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.101452Z", + "before_version": 6, + "after_version": 7, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_members": [], + "registration_removal_id": 1, + "revoked_invitation_count": 1 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/solutions/60/history/9/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 9, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.101452Z", + "before_version": 6, + "after_version": 7, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_members": [], + "registration_removal_id": 1, + "revoked_invitation_count": 1 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [124] + }, + "response": { + "registrations": [ + { + "registration_id": 124, + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 7, + "is_submitted": true, + "effect": "remove_members", + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 2 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [124], + "preview_token": "" + }, + "response": { + "audit_id": 2, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 8, + "change_id": 10, + "action": "remove_registrations" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [124], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/2/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 2, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.967511Z", + "registrations": [ + { + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 124 + } + ], + "deleted_count": 1 + }, + "changes": [ + { + "id": 10, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.982484Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 2, + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/solutions/60/history/10/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 10, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.982484Z", + "before_version": 7, + "after_version": 8, + "removed_assignments_count": 0, + "score_count": 2, + "was_submitted": true, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 2, + "revoked_invitation_count": 0 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + }, + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.918099Z", + "is_captain": false, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/43/solutions/60/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": null + }, + { + "actor": "member", + "method": "GET", + "path": "/solution-invitations/64/", + "status": 200, + "request": null, + "response": { + "id": 64, + "version": 2, + "program": { + "id": 43, + "name": "Удаление регистраций case qa01reg-20261025-c45626d0" + }, + "solution": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "captain": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "recipient": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "motivational_letter": null, + "status": "accepted", + "created_at": "2026-10-25T16:20:51.746859Z", + "updated_at": "2026-10-25T16:20:51.929766Z", + "permissions": { + "can_edit": false, + "can_revoke": false, + "can_accept": false, + "can_decline": false + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [123, 126] + }, + "response": { + "registrations": [ + { + "registration_id": 123, + "user": { + "id": 19, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + }, + { + "registration_id": 126, + "user": { + "id": 22, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 8, + "is_submitted": true, + "effect": "archive", + "removed_members": [], + "removed_assignments_count": 2, + "revoked_invitation_count": 0, + "score_count": 2 + }, + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 63, + "name": "Работа extra qa01reg-20261025-c45626d0" + }, + "solution_version": 1, + "is_submitted": false, + "effect": "archive", + "removed_members": [], + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0 + } + ], + "deleted_count": 2, + "archived_count": 2, + "removed_member_count": 0, + "removed_assignments_count": 2, + "revoked_invitation_count": 0, + "score_count": 2, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [126, 123], + "preview_token": "" + }, + "response": { + "audit_id": 3, + "deleted_count": 2, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 9, + "change_id": 11, + "action": "archive" + }, + { + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 63, + "name": "Работа extra qa01reg-20261025-c45626d0" + }, + "solution_version": 2, + "change_id": 12, + "action": "archive" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [126, 123], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/3/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 3, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.221440Z", + "registrations": [ + { + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 123 + }, + { + "user": { + "id": 22, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 126 + } + ], + "deleted_count": 2 + }, + "changes": [ + { + "id": 11, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.243734Z", + "before_version": 8, + "after_version": 9, + "removed_assignments_count": 2, + "score_count": 2, + "was_submitted": true, + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed", + "revoked_invitation_count": 0, + "registration_removal_id": 3 + }, + { + "id": 12, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 63, + "name": "Работа extra qa01reg-20261025-c45626d0" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.262521Z", + "before_version": 1, + "after_version": 2, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed", + "revoked_invitation_count": 0, + "registration_removal_id": 3 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/solutions/60/history/11/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 11, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 60, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.243734Z", + "before_version": 8, + "after_version": 9, + "removed_assignments_count": 2, + "score_count": 2, + "was_submitted": true, + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed", + "revoked_invitation_count": 0, + "registration_removal_id": 3 + }, + "before_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "after_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 19, + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа captain qa01reg-20261025-c45626d0", + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 19, + "captured_at": "2026-10-25T16:20:59.222947Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/solutions/63/history/12/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 12, + "application": { + "kind": "solution", + "program_id": 43, + "solution_id": 63, + "name": "Работа extra qa01reg-20261025-c45626d0" + }, + "action": "archive", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.262521Z", + "before_version": 1, + "after_version": 2, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed", + "revoked_invitation_count": 0, + "registration_removal_id": 3 + }, + "before_snapshot": null, + "after_snapshot": null, + "archive_snapshot": { + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "name": "Работа extra qa01reg-20261025-c45626d0", + "answers": [], + "members": [ + { + "role": null, + "user": { + "id": 22, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:55.362746Z", + "is_captain": true, + "specialization": null + } + ], + "captain_id": 22, + "captured_at": "2026-10-25T16:20:59.222947Z" + } + }, + "cacheControl": "private, no-store" + }, + { + "actor": "captain", + "method": "GET", + "path": "/programs/43/solutions/60/", + "status": 200, + "request": null, + "response": { + "id": 60, + "program_id": 43, + "version": 9, + "name": "Работа captain qa01reg-20261025-c45626d0", + "captain_id": 19, + "created_at": "2026-10-25T16:20:51.346683Z", + "updated_at": "2026-10-25T16:20:59.222947Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:51.354991Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "answers": [ + { + "value": "Город", + "field_id": 293 + }, + { + "value": "Материалы qa01reg-20261025-c45626d0", + "field_id": 294 + }, + { + "value": 0, + "field_id": 295 + }, + { + "value": false, + "field_id": 296 + } + ], + "submission": { + "required": true, + "status": "submitted", + "submission_open": false, + "deadline": "2026-10-24T16:20:58.625346Z", + "submitted_at": "2026-10-25T16:20:53.016408Z" + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "extra", + "method": "GET", + "path": "/programs/43/solutions/63/", + "status": 200, + "request": null, + "response": { + "id": 63, + "program_id": 43, + "version": 2, + "name": "Работа extra qa01reg-20261025-c45626d0", + "captain_id": 22, + "created_at": "2026-10-25T16:20:55.355057Z", + "updated_at": "2026-10-25T16:20:59.222947Z", + "participation": { + "status": "archived", + "archived_at": "2026-10-25T16:20:59.222947Z", + "reason": "registration_removed" + }, + "team": { + "members": [ + { + "role": null, + "user": { + "id": 22, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:55.362746Z", + "is_captain": true, + "specialization": null + } + ], + "accepted_count": 1, + "pending_count": 0, + "reserved_count": 1, + "min_size": 1, + "max_size": 4, + "is_frozen": true + }, + "form": { + "fields": [ + { + "id": 293, + "type": "select", + "label": "Кейс", + "options": ["Город", "Образование"], + "purpose": "case", + "position": 0, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 294, + "type": "textarea", + "label": "Материалы", + "options": [], + "purpose": "other", + "position": 1, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 295, + "type": "number", + "label": "Число", + "options": [], + "purpose": "other", + "position": 2, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + }, + { + "id": 296, + "type": "checkbox", + "label": "Флаг", + "options": [], + "purpose": "other", + "position": 3, + "required": true, + "help_text": "", + "validation": { + "maximum": null, + "minimum": null, + "max_length": null, + "min_length": null, + "allowed_extensions": [], + "max_file_size_bytes": null + } + } + ], + "revision": "123ee68a8411c1610004b187c7025f3755cc61a62e74654bfba428f6ce72c992", + "template": "case_championship", + "program_id": 43, + "is_snapshot": true, + "is_configured": true + }, + "answers": [], + "submission": { + "required": true, + "status": "draft", + "submission_open": false, + "deadline": "2026-10-24T16:20:58.625346Z", + "submitted_at": null + }, + "permissions": { + "can_edit": false, + "can_manage_team": false, + "can_invite": false, + "can_submit": false, + "can_leave": false + }, + "readiness": { + "is_ready": false, + "issues": [ + { + "path": "participation", + "code": "solution_archived", + "detail": "Решение снято с участия.", + "field_id": null + } + ] + } + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/solutions/", + "status": 200, + "request": null, + "response": { + "count": 0, + "next": null, + "previous": null, + "results": [] + }, + "cacheControl": null + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [127, 128, 129] + }, + "response": { + "registrations": [ + { + "registration_id": 127, + "user": { + "id": 23, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + }, + { + "registration_id": 128, + "user": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + } + }, + { + "registration_id": 129, + "user": { + "id": 15, + "first_name": "Роман", + "last_name": "Эксперт", + "avatar_url": null + } + } + ], + "effects": [], + "deleted_count": 3, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [129, 128, 127], + "preview_token": "" + }, + "response": { + "audit_id": 4, + "deleted_count": 3, + "items": [] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/43/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [129, 128, 127], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/4/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 4, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:01.222833Z", + "registrations": [ + { + "user": { + "id": 23, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 127 + }, + { + "user": { + "id": 8, + "last_name": "Проверки", + "avatar_url": null, + "first_name": "Олег" + }, + "registration_id": 128 + }, + { + "user": { + "id": 15, + "last_name": "Эксперт", + "avatar_url": null, + "first_name": "Роман" + }, + "registration_id": 129 + } + ], + "deleted_count": 3 + }, + "changes": [] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [131] + }, + "response": { + "registrations": [ + { + "registration_id": 131, + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 4, + "is_submitted": false, + "effect": "remove_members", + "removed_members": [ + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:53.725029Z", + "is_captain": false + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [131], + "preview_token": "" + }, + "response": { + "audit_id": 5, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 5, + "change_id": 13, + "action": "remove_registrations" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [131], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/44/management/registrations/removals/5/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 5, + "program_id": 44, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:02.207028Z", + "registrations": [ + { + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 131 + } + ], + "deleted_count": 1 + }, + "changes": [ + { + "id": 13, + "application": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:02.222881Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:53.725029Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 5, + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/44/management/solutions/61/history/13/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 13, + "application": { + "kind": "solution", + "program_id": 44, + "solution_id": 61, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:02.222881Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:53.725029Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 5, + "revoked_invitation_count": 0 + }, + "before_snapshot": null, + "after_snapshot": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/45/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [134] + }, + "response": { + "registrations": [ + { + "registration_id": 134, + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [ + { + "application": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 4, + "is_submitted": false, + "effect": "remove_members", + "removed_members": [ + { + "user": { + "id": 20, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + }, + "role": "Аналитик", + "specialization": null, + "joined_at": "2026-10-25T16:20:54.696345Z", + "is_captain": false + } + ], + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0 + } + ], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 1, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/45/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [134], + "preview_token": "" + }, + "response": { + "audit_id": 6, + "deleted_count": 1, + "items": [ + { + "application": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "solution_version": 5, + "change_id": 14, + "action": "remove_registrations" + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/45/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [134], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/45/management/registrations/removals/6/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 6, + "program_id": 45, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:03.227002Z", + "registrations": [ + { + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 134 + } + ], + "deleted_count": 1 + }, + "changes": [ + { + "id": 14, + "application": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:03.242113Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:54.696345Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 6, + "revoked_invitation_count": 0 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/45/management/solutions/62/history/14/", + "status": 200, + "request": null, + "response": { + "change": { + "id": 14, + "application": { + "kind": "solution", + "program_id": 45, + "solution_id": 62, + "name": "Работа captain qa01reg-20261025-c45626d0" + }, + "action": "remove_registrations", + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:21:03.242113Z", + "before_version": 4, + "after_version": 5, + "removed_assignments_count": 0, + "score_count": 0, + "was_submitted": false, + "removed_members": [ + { + "role": "Аналитик", + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "joined_at": "2026-10-25T16:20:54.696345Z", + "is_captain": false, + "specialization": null + } + ], + "registration_removal_id": 6, + "revoked_invitation_count": 0 + }, + "before_snapshot": null, + "after_snapshot": null + }, + "cacheControl": "private, no-store" + }, + { + "actor": "member", + "method": "GET", + "path": "/programs/43/management/registrations/removals/", + "status": 403, + "request": null, + "response": { + "code": "permission_denied", + "detail": "Действие доступно организатору программы.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/44/management/registrations/removals/2/", + "status": 404, + "request": null, + "response": { + "code": "not_found", + "detail": "Решение или программа не найдены.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/?offset=1&limit=2", + "status": 200, + "request": null, + "response": { + "count": 4, + "offset": 1, + "limit": 2, + "results": [ + { + "id": 3, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:59.221440Z", + "registrations": [ + { + "user": { + "id": 19, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 123 + }, + { + "user": { + "id": 22, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 126 + } + ], + "deleted_count": 2 + }, + { + "id": 2, + "program_id": 43, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:20:57.967511Z", + "registrations": [ + { + "user": { + "id": 20, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 124 + } + ], + "deleted_count": 1 + } + ] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/43/management/registrations/removals/?all=true", + "status": 400, + "request": null, + "response": { + "code": "validation_error", + "detail": "Проверьте данные запроса.", + "errors": [ + { + "path": "all", + "code": "invalid", + "detail": "Неизвестное поле.", + "field_id": null + } + ], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [132] + }, + "response": { + "registrations": [ + { + "registration_id": 132, + "user": { + "id": 21, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [132], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/preview/", + "status": 200, + "request": { + "registration_ids": [132] + }, + "response": { + "registrations": [ + { + "registration_id": 132, + "user": { + "id": 21, + "first_name": "Program", + "last_name": "User", + "avatar_url": null + } + } + ], + "effects": [], + "deleted_count": 1, + "archived_count": 0, + "removed_member_count": 0, + "removed_assignments_count": 0, + "revoked_invitation_count": 0, + "score_count": 0, + "can_apply": true, + "errors": [], + "preview_token": "" + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/apply/", + "status": 200, + "request": { + "registration_ids": [132], + "preview_token": "" + }, + "response": { + "audit_id": 7, + "deleted_count": 1, + "items": [] + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "POST", + "path": "/programs/44/management/registrations/remove/apply/", + "status": 409, + "request": { + "registration_ids": [132], + "preview_token": "" + }, + "response": { + "code": "solution_action_conflict", + "detail": "Данные решений изменились. Проверьте их и подтвердите действие заново.", + "errors": [], + "meta": {} + }, + "cacheControl": "private, no-store" + }, + { + "actor": "manager", + "method": "GET", + "path": "/programs/44/management/registrations/removals/7/", + "status": 200, + "request": null, + "response": { + "audit": { + "id": 7, + "program_id": 44, + "actor": { + "id": 8, + "first_name": "Олег", + "last_name": "Проверки", + "avatar_url": null + }, + "actor_name": "Олег Проверки", + "created_at": "2026-10-25T16:23:50.358524Z", + "registrations": [ + { + "user": { + "id": 21, + "last_name": "User", + "avatar_url": null, + "first_name": "Program" + }, + "registration_id": 132 + } + ], + "deleted_count": 1 + }, + "changes": [] + }, + "cacheControl": "private, no-store" + } +] diff --git a/docs/qa/program-solutions-registration-removal-20261025/migration-evidence.json b/docs/qa/program-solutions-registration-removal-20261025/migration-evidence.json new file mode 100644 index 00000000..b85527ae --- /dev/null +++ b/docs/qa/program-solutions-registration-removal-20261025/migration-evidence.json @@ -0,0 +1,38 @@ +{ + "status": "PASS", + "migration": "0038_solution_registration_removal_audit", + "database": "local-procollab_ci", + "oldModelsUnchanged": 23, + "hashBefore": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "hashAfter": "717c9a664170764394fd1f9a256fca7c78fa42d929abe0e85a4557be1f684e3d", + "rowCounts": { + "partner_programs.PartnerProgram": 40, + "partner_programs.PartnerProgramField": 281, + "partner_programs.PartnerProgramUserProfile": 116, + "partner_programs.PartnerProgramProject": 3, + "partner_programs.PartnerProgramFieldValue": 2, + "partner_programs.ProgramSolution": 58, + "partner_programs.ProgramSolutionAnswer": 273, + "partner_programs.ProgramSolutionMember": 80, + "partner_programs.SolutionInvitation": 38, + "project_rates.SolutionScore": 34, + "project_rates.SolutionExpertAssignment": 20, + "project_rates.Criteria": 69, + "users.Expert": 7, + "files.UserFile": 8, + "projects.Project": 4, + "projects.Collaborator": 6, + "invites.Invite": 4, + "project_rates.ProjectScore": 3, + "project_rates.ProjectExpertAssignment": 1, + "industries.Industry": 1, + "users.Expert_programs": 27, + "partner_programs.PartnerProgram_managers": 44, + "partner_programs.SolutionAdministrativeChange": 8 + }, + "existingAdminEventsPreserved": 8, + "existingArchivedSolutionsPreserved": 4, + "existingRowsDeletedOrArchived": 0, + "existingProjectsMigratedOrDeleted": 0, + "onlyLocalSchema": true +} diff --git a/docs/qa/program-solutions-submit-actions-20261025/README.md b/docs/qa/program-solutions-submit-actions-20261025/README.md new file mode 100644 index 00000000..c7284a75 --- /dev/null +++ b/docs/qa/program-solutions-submit-actions-20261025/README.md @@ -0,0 +1,13 @@ +# QA01: первая сдача организатором + +Проверен API существующего привилегированного сценария: организатор впервые сдает готовое решение после дедлайна через signed preview/confirmation. Обычная сдача капитана, уже сданный snapshot и project actions сохраняют правила. [Spec и audit SADM01–SADM10](../../product/program-solutions-be08-submit-actions.md). Общий первый выпуск не завершен. + +[Final HTTP evidence](http-evidence.json): 42 настоящих HTTP/JWT запроса с проверкой request/response C00 1.14.0, обе формы, программы 19/20, решения 26/28 и сданные anchors 27/29. Использованы отдельный локальный API 8877, PostgreSQL 18/main procollab_ci, locmem mail; только новые синтетические fixtures. После закрытия срока и выключения creation flag капитан получает submission_closed, manager проходит preview → confirmation. Устаревшее подтверждение после реального PATCH и replay получают solution_action_conflict. Mixed action дает changed=1/already=1 и сохраняет anchor. Snapshot включает принятый состав, число 0, false и сохраненные поля; pending не становится членством. All-submitted preview дает null token. Последующий PATCH сданного решения отклоняется. + +Preview не меняет domain data. Hash прежних строк 11 выбранных моделей совпал до/после финального прогона; Project не создавался. Пользователи и file owner использованы только из прежних синтетических QA аккаунтов. Presentation field явно настроен optional; капитан прикладывает ранее зарегистрированный собственный проверенный PDF. Binary upload/CDN delivery этим прогоном не повторяются. JWT и подписанные action tokens не включены в evidence; SHA256 ответов записан после schema validation. + +Промежуточный HTTP прогон на программах 17/18 также прошел 42 checks. После review найден gap между fingerprint файла и его финальной блокировкой; file locks перенесены до проверки состояния. Локальный worker остановлен по проверенным PID/command line и запущен на новом source, final HTTP повторен на свежих 19/20; прежние работы не удалялись и не перезаписывались. Оба ранних creation flags выключены; final 19/20 выключают создание до manager submit. Фикстуры сохраняются для проверки. + +Checked by tests: final 325 PostgreSQL tests решения/оценивания/legacy actions PASS, из них 17 новых. Confirm×confirm и confirm×captain PATCH наблюдают настоящие wait paths обоих PostgreSQL worker до снятия program lock. Final `npm run test:ci` — 2793 tests/479 files + strict C00 types PASS; 30 новых wire tests используют настоящий CamelcaseInterceptor и API adapter. Ранний unused @ts-expect-error был исправлен по месту TS property error; итоговый полный прогон прошел. Development build/scoped ESLint, Black/Flake8/Django/migration consistency/C00 PASS. + +[Source provenance](source-provenance.json) фиксирует SHA256 runtime файлов, contract files и helper. Это API проверка; manager UI, browser flow D02, production, большой объем выборки и новый Linux CI не подменяются локальными результатами. Старый зеленый backend 6db9050 относится к предыдущему source. Текущие feature PR остаются draft. diff --git a/docs/qa/program-solutions-submit-actions-20261025/http-evidence.json b/docs/qa/program-solutions-submit-actions-20261025/http-evidence.json new file mode 100644 index 00000000..842d1ac7 --- /dev/null +++ b/docs/qa/program-solutions-submit-actions-20261025/http-evidence.json @@ -0,0 +1,421 @@ +{ + "status": "PASS", + "prefix": "sadm-20261025-d2687752", + "c00": "1.14.0", + "programs": [ + { + "id": 19, + "template": "case_championship", + "solutionId": 26, + "anchorId": 27, + "snapshotSha256": "428907c0bbed8a0e8cf711ae2c797612f77289ee656070b1416165665468a4fc", + "readyThenMixedResult": { + "changed_count": 1, + "already_submitted_count": 1 + }, + "anchorUnchanged": true, + "acceptedOnly": true, + "zeroFalsePreserved": true, + "previewReadOnly": true, + "replayNoMutation": true, + "deadlineBypassOnlyManager": true, + "creationFlagOffDuringSubmission": true + }, + { + "id": 20, + "template": "hackathon", + "solutionId": 28, + "anchorId": 29, + "snapshotSha256": "e3a4897932993325889ee962721d14bf91f07b94ca682e4464b217295619a9d0", + "readyThenMixedResult": { + "changed_count": 1, + "already_submitted_count": 1 + }, + "anchorUnchanged": true, + "acceptedOnly": true, + "zeroFalsePreserved": true, + "previewReadOnly": true, + "replayNoMutation": true, + "deadlineBypassOnlyManager": true, + "creationFlagOffDuringSubmission": true + } + ], + "events": [ + { + "role": "manager", + "method": "GET", + "path": "/programs/19/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "07f8642b9d3dda796c0d7143c2f4ee6a1fac0eb75fc28a2d791308bbc3ef676a" + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/19/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "84a27d9a5d2e55781a914b69f82d43f7c80330c70413c32334ff9a26c6ff7797" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/19/solutions/", + "status": 201, + "code": null, + "responseSha256": "cd4e66090e70309c5d3651259878e4d7a857d159ad5abd243cefce41fe3934e2" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/19/solutions/26/", + "status": 200, + "code": null, + "responseSha256": "b8642ace8be3dcb0cfd9df40fc4d04373ed274fdfafc31a2fadcbe8edb373aa4" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/19/solutions/", + "status": 201, + "code": null, + "responseSha256": "08295dd5798e4392abea3b25042c28896dd9952f9f9427a73cc46ac43781ca47" + }, + { + "role": "pending_a", + "method": "PATCH", + "path": "/programs/19/solutions/27/", + "status": 200, + "code": null, + "responseSha256": "dd8b05fe13e57ca3fa2d106a682296c2462e94e71fac2a0e2a44dad1e7286066" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/19/solutions/27/submit/", + "status": 200, + "code": null, + "responseSha256": "11fb2b2f895e3aa096ac08f43f1a4a1213a009ad0d2df03a7fa4f2bcbcb516d4" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/19/solutions/26/invitations/", + "status": 201, + "code": null, + "responseSha256": "9992b3c5596dbe15579abbaa2a99514336215e6d34edd0f9242dc63706ad4175" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/19/solutions/26/", + "status": 200, + "code": null, + "responseSha256": "884aaf03127cd210a9ae431ea13be9230edb67d423421a39328b2608996b4c19" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/19/solutions/26/submit/", + "status": 409, + "code": "submission_closed", + "responseSha256": "df70a16768a29640ee57fcc6b986febe001c9ee5e02920d3bbf6d8d44c8c6126" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb", + "cacheControl": "private, no-store" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb", + "cacheControl": "private, no-store" + }, + { + "role": null, + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 401, + "code": "authentication_required", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "66ec7a454fd8e8a4c2fb59ec4c8370c56338aabc7d66bfb9803a5d47857c0cfe", + "cacheControl": "private, no-store" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/19/solutions/26/", + "status": 200, + "code": null, + "responseSha256": "dd7a952c2d27c53b9785e824f76159d393bb42cbbc2d71e0036d4c95070a9b61" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "9f5ed0847d74874bc0c61bbbe6c02dedc081c05b50deb0405e9ef0ffa304684d", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "be0a1f664aa9425d333dee3ebe82f4a54cd7b644f8b6f3a3e49ba5cd5bdad58d", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/apply/", + "status": 200, + "code": null, + "responseSha256": "988555c371e642839c4399fc589a347a4371ee74739daecaa7d2f22196d04eab", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "9f5ed0847d74874bc0c61bbbe6c02dedc081c05b50deb0405e9ef0ffa304684d", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/19/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "2cb6aefe0ebab082b71a7b00e789930b365a7b47de6a5c90e67d4a8ad51868da", + "cacheControl": "private, no-store" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/19/solutions/26/", + "status": 409, + "code": "team_frozen", + "responseSha256": "cc1daf856735cacd081f53d06e10bfcb853ebe3f4624b0efe1eb86dca54633ed" + }, + { + "role": "manager", + "method": "GET", + "path": "/programs/20/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "4f9e70c907aab07638895d0c26219bb4531b26eb1fe4bd287161bd79bcbb1429" + }, + { + "role": "manager", + "method": "PUT", + "path": "/programs/20/management/solution-form/", + "status": 200, + "code": null, + "responseSha256": "05ed69b6d8b8dec9b6bd48250fdd2e38b9c563a9dbcc4a79dab4f2177392d531" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/20/solutions/", + "status": 201, + "code": null, + "responseSha256": "2641f9d818e950ba035815f55b523d7195150146c228043694d34d8a0ef10a0f" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/20/solutions/28/", + "status": 200, + "code": null, + "responseSha256": "6b429e1d4af52dfbd7ac245195e8e6a279832e41463ca29df7bca5e1aabd7795" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/20/solutions/", + "status": 201, + "code": null, + "responseSha256": "7ebea05e97b94d0578c2fc31cf043ec4e56cb6bbf1f69272cc862e0b8d7d5100" + }, + { + "role": "pending_a", + "method": "PATCH", + "path": "/programs/20/solutions/29/", + "status": 200, + "code": null, + "responseSha256": "81bac503f84d1e422be680aee33e0f32ceed181515b54f2c8ed5b5d6516ffb44" + }, + { + "role": "pending_a", + "method": "POST", + "path": "/programs/20/solutions/29/submit/", + "status": 200, + "code": null, + "responseSha256": "43971f2aaf96eba412b4caf6adfd67cdd1f6990fa8d67b1943d3cbcc8c5ab5be" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/20/solutions/28/invitations/", + "status": 201, + "code": null, + "responseSha256": "e778af0e5385963729e4905529d6623494ab408c06ca466d97432576bfc3d995" + }, + { + "role": "captain", + "method": "GET", + "path": "/programs/20/solutions/28/", + "status": 200, + "code": null, + "responseSha256": "b6f5e4481e4b07fc8696fb1f922e923e096bb3f85601b48d86df5d820eac3ff2" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/20/solutions/28/submit/", + "status": 409, + "code": "submission_closed", + "responseSha256": "df70a16768a29640ee57fcc6b986febe001c9ee5e02920d3bbf6d8d44c8c6126" + }, + { + "role": "captain", + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb", + "cacheControl": "private, no-store" + }, + { + "role": "member", + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 403, + "code": "permission_denied", + "responseSha256": "ab1a699a6b0e65ddc2dd7b6857258dde33fbabcb9d9acb46fb25a0591ea96abb", + "cacheControl": "private, no-store" + }, + { + "role": null, + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 401, + "code": "authentication_required", + "responseSha256": "9a7fd910796627107ce589a0a564bf51b6d16b20c59430df81d2a2f5628cfb09", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "de2e10365669848944ad2a7000d3890dfc1baf7426c7acee29f58a73b6d71bc9", + "cacheControl": "private, no-store" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/20/solutions/28/", + "status": 200, + "code": null, + "responseSha256": "28c582ab18390dc6f835d36cb5847487852103635997ce73cba2040e0b4a1666" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "9f5ed0847d74874bc0c61bbbe6c02dedc081c05b50deb0405e9ef0ffa304684d", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "1cdeafd66219c9d370a60006a71144696801a28a81507fd0afeb7f3011fa50c5", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/apply/", + "status": 200, + "code": null, + "responseSha256": "988555c371e642839c4399fc589a347a4371ee74739daecaa7d2f22196d04eab", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/apply/", + "status": 409, + "code": "solution_action_conflict", + "responseSha256": "9f5ed0847d74874bc0c61bbbe6c02dedc081c05b50deb0405e9ef0ffa304684d", + "cacheControl": "private, no-store" + }, + { + "role": "manager", + "method": "POST", + "path": "/programs/20/management/solutions/actions/preview/", + "status": 200, + "code": null, + "responseSha256": "f1490a91a1a12543ab7153ef579e4a1fdd5032eb96e93d19c1c2ed409611b20c", + "cacheControl": "private, no-store" + }, + { + "role": "captain", + "method": "PATCH", + "path": "/programs/20/solutions/28/", + "status": 409, + "code": "team_frozen", + "responseSha256": "cc1daf856735cacd081f53d06e10bfcb853ebe3f4624b0efe1eb86dca54633ed" + } + ], + "httpChecks": 42, + "preexistingSelectedDomainUnchanged": true, + "preexistingModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "files.UserFile", + "projects.Project" + ], + "projectCountUnchanged": true, + "limitations": [ + "Only fresh synthetic fixtures changed. Existing deadline changes were never made.", + "This is a real HTTP/JWT API run; manager UI/D02 and production are not checked.", + "Preview tokens and JWT are omitted. Response SHA256 was recorded only after actual C00 validation.", + "Presentation field was configured optional for this fixture. Captain uses an existing owned verified PDF; binary upload/CDN delivery are not repeated here." + ] +} diff --git a/docs/qa/program-solutions-submit-actions-20261025/source-provenance.json b/docs/qa/program-solutions-submit-actions-20261025/source-provenance.json new file mode 100644 index 00000000..0f81f816 --- /dev/null +++ b/docs/qa/program-solutions-submit-actions-20261025/source-provenance.json @@ -0,0 +1,31 @@ +{ + "backendBase": "6db9050c2d1eb7313006a285566501a104dbc415", + "frontendBase": "03f3b123daf0691303f5bbc7ff06c6682e45be01", + "sourceDirectory": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-submit-actions", + "sourceRawEvidenceSha256": "ab321164f6cdb77674077dab50cdcf66510f0287866acdd68cfb07df2dc92255", + "previousHttpEvidenceSha256": "cf34418c70bf20fb18d3dc7317539945f28f3b3c006f8753755deaffc8d311e5", + "helperSha256": "04d536060fc357ef858515f9914879032acca5f34aa3634f24b874c5bcef5d1c", + "sources": { + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\partner_programs\\solution_submission.py": "01518f2cd0cf6ec40de2b392f51bfc01d0da599680a5c7219f5ff69f1465a393", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\partner_programs\\services\\solution_submission_actions.py": "7c629683b99fcfcfbba47db4aba3d1a801f5a8bbdb3daa3ea4834b01694ab41b", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\partner_programs\\solution_action_api.py": "1d5310ff5149f1ad1232517923730483d7ebd13f6afc7e5f69140c84c30a18b4", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\partner_programs\\urls.py": "84037edacd3b3b4cc1970925f49b636a852430fedb799cbb33b541a24d6eec0c", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\partner_programs\\tests\\test_solution_submission_actions.py": "39c523902747c7abde28da7f9ae9e6f30d401d3684e4b76ac2358dbe69900388", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\docs\\contracts\\program-solutions-v1\\openapi.json": "792053242e796730d24ed4482100ec6a33d4db055b4a96f1929795a013e17fa4", + "Z:\\Документы\\PROCOLLAB-dev\\api-dev-program-solutions-be01-20261024\\docs\\contracts\\program-solutions-v1\\examples.json": "7e024b2ffd88bf473728044c960d53634f85c489f4af40c3dc91a3fd490d7208", + "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\projects\\social_platform\\src\\app\\api\\solution\\use-cases\\solution-submission-actions.use-cases.ts": "7a0f915ae098f73a59d49d0dce12231cd16319732eaaca7fd46e2c82719ae7ec", + "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\projects\\social_platform\\src\\app\\api\\solution\\solution-submission-actions-contract.spec.ts": "442a385d7bccdec2dde6deb3f26b13b6a115d4dff1c3a1f6ea149bd7a4c9950f", + "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\projects\\social_platform\\src\\app\\infrastructure\\adapters\\solution\\solution-http.adapter.ts": "f719265437355e9638231601bf3136c1fe641fa25b472629c1d76ff876a6863f", + "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\projects\\social_platform\\src\\app\\domain\\solution\\solution-contract.model.ts": "7f85aa7a8643c20d34a73514f81a82d478f30a5a5ef506cb861e4efb5be3ee2d", + "Z:\\Документы\\PROCOLLAB-dev\\procollab-dev-program-solutions-fe01-20261024\\projects\\social_platform\\src\\app\\domain\\solution\\solution-contract.fixture.ts": "45e14979d41442a4c63f4ae547e063385271c8554750b3e28b52e3d6c941a879" + }, + "localLogs": { + "procollab-sadm-pg-broad-final.log": "841094b48f835c020a7a21af24952fc109b67d7cec20f32597f5f30e57900063", + "procollab-sadm-tests-final.log": "3112646700c91f95a8ff32b72e03090a51bb02f842aeecb93469dfa36666d2c8", + "procollab-sadm-fe-all-final.log": "7a41cab661eee120db334100904c80e13216724c1768e6099019a31ad3b2d85f", + "procollab-sadm-fe-build.log": "e304385b61cc278af9c708bec2b3105fccc3b0d5d218c3774e6a3904651ff42c", + "procollab-sadm-http-final.log": "69ce887ddcf5f20600bf5f4110492ad8a17d951183b42a61a074dbd17514f536" + }, + "credentialsIncluded": false, + "limits": "Source hashes identify the verified runtime before documentation/commit; no production or UI browser flow was used." +} diff --git a/docs/qa/program-solutions-team-acceptance-20261025/README.md b/docs/qa/program-solutions-team-acceptance-20261025/README.md new file mode 100644 index 00000000..8e26b8cd --- /dev/null +++ b/docs/qa/program-solutions-team-acceptance-20261025/README.md @@ -0,0 +1,42 @@ +# QA01 — команда внутри заявки и обязательная роль + +25.10.2026. Подтверждены AC04/05/07/09/24/25/26 в указанном ниже scope. **Общий QA01 и REL01 остаются открытыми.** [Исходная матрица26 AC](../../product/program-solutions-qa01.md), [каноническое ТЗ](https://chatgpt.com/space/page_9632e9ed74588191b6b3e4b397d55175). + +## Исправленное расхождение AC26 + +Согласованный D00 v3 требует выбранный ID и роль1–128. Форма уже проверяла роль, но прежний API разрешал отсутствующую/null/пустую роль. C001.16.0 намеренно ужесточает только CreateInvitation и UpdateInvitation: новая роль обязательна, хотя бы один символ непробельный, длина1–128; PATCH другой информации может не передавать role. Переданную role нельзя очистить. Значение не переписывается и пробелы валидной пользовательской роли не нормализуются сервером. + +Проверка действует в HTTP serializer и transactional service. Неправильная роль дает400/validation_error до записи, versions/members/invitations не меняются. Исторические nullable role в Invitation/Member сохранены: чтение/принятие/отклонение/отзыв и частичный PATCH другого поля работают без выдуманной роли. Django admin показывает приглашения readonly; модели, миграции, legacy Project API и пользовательские записи не переписывались. + +[Compatibility audit](evidence.json):128 schemas/49 operations/217 scenarios. Менялись только2 request schemas,126 schemas/все49 operation definitions прежние. В трех старых create error mocks добавлена корректная роль, чтобы сохранить проверку duplicate/max/frozen;206 остальных сценариев неизменны.8 новых сценариев проверяют границы/ошибки/историческую null role. Типы/fixtures фронта сгенерированы из той же копии C00; strict type checks запрещают missing role и clearing null. Изменений UI нет. + +## Критерии и конкретное доказательство + +| AC | Проверенное поведение | Доказательство | +| --- | --- | --- | +| AC04 | Команда и pending находятся во второй вкладке того же решения; отдельный выбор Team отсутствует | PART01 программы7/8: local description → Team → materials без потери; create/accept возвращают тот же solution_id; team component и общий invite form совпадают с INV runtime по source audit | +| AC05 | Капитан отправляет/правит/отзывает; получатель принимает/отклоняет; посторонний не управляет приглашением; статус/versions переходят без подмены членства | PART01 actual двух аккаунтов, INV05/06 edit409/retry200/revoke204; прочитаны конкретные asserts test_solution_invitations create/read/accept, private roles, patch/version, revoke/history, повтор processed409 | +| AC07 | Pending резервирует максимум, минимум учитывает капитана и accepted; принятие заменяет свой резерв | INV01/02: два worker ждут общий PostgreSQL program lock,201/409; retry409/team_max_size,1/1/2 → accept2/0/2. NEG: pending-only submit preparation остается draft/team_min_size и фокусирует Team; после accept разрешает переход к материалам. Прочитаны asserts tests team/reservations и submission/minimum | +| AC09 | После submit нельзя менять ответы/accepted roster или принимать pending; decline/revoke pending сохраняют снимок | PART01 frozenTail сравнивает form/answers/members до/после recipient decline и captain DELETE204. Backend asserts reject PATCH/create/accept/remove/leave/transfer; snapshot/status неизменны. Этот PASS не согласовывает административные исключения BE08 — их политика отдельно открыта | +| AC24 | Участник завершает форму, приглашение и сдачу на мобильном экране | PART01 обе формы: основной путь390px, actual поиск/выбор/роль/inbox accept/PDF/0/false/case, cancel затем один submit200; frozen state/readback. Ширины320/390/768/1440 проверяли названные состояния, полный desktop/a11y/zoom не заявляется | +| AC25 | Вкладки сохраняют ввод и кейс; сдача проверяет обе вкладки и открывает точную ошибку | PART01 сохраняет text/0/false/case после переходов и PATCH. NEG01/02 открывает Team/team-title для team_min_size и materials/field control для missing case/file/repository; из Team возвращается к нужному control. Текущие route tests и ACCESS подтверждают вкладки/очистку без переноса правок между аккаунтами | +| AC26 | Существующий modal/search по имени в программе, selected ID, required role1–128, свободная роль/подсказки, unavailable, pending/error/retry/edit/revoke | INV03–INV06 реальные обе формы/границы/409 с сохранением выбора и роли; wire requests используют ID. Новый backend role fix закрывает прямой обход UI. Current API46 responses/38 invalid requests дают400; create/PATCH не меняют15 domain моделей. Legacy null read/accept/decline/revoke и boundaries покрыты отдельными10 backend tests | + +## Новая проверка и исторические refs + +Новый runtime API — backend base ef314c30e3491e15b8232413000597b3f9df1456 + exact source hashes в [HTTP evidence](http-evidence.json).46 реальных responses по C001.16,38 намеренно неверных request bodies отклонены400 и самим schema validator. Только существующие synthetic программы30/31, решения45/47, приглашения51/52. Полные hashes15 domain моделей совпали; новых fixtures, Project и успешных domain writes нет. + +Текущий локальный regression:340 PostgreSQL solution tests PASS, включая team/invitations/submission/evaluation.10 новых role tests проверяют HTTP, прямой service, boundaries и historical null. Первоначальный focused68 PASS предшествовал только исправлению структуры одного test context/Black; final340 выполнен после них. Ранний red сохранен в Temp: missing role реально давала201; последующие проверки имели каскадные assertions и ошибку test context. Число ранних failures не выдается за независимые продуктовые дефекты. + +Frontend:2843 tests/480 files, strict C00 types, development build, scoped ESLint и locked Prettier3.8.3 PASS.6 новых wire tests проверяют1/128,400 без retry и historical null read; текущая группа team/modal/wire26 PASS. Product frontend source не менялся кроме generated types/fixtures. Макет D00 v3 и существующий путь приглашения сохранены. + +Исторические источники сохранены с их границами: + +- [PART01](../program-solutions-participant-20261025/README.md): backend1724bca/frontend e57fb04/C001.12, фактические фазы и отдельный resumed final readback; не один непрерывный последний запуск. Ранний final assertion прочитал pending до DELETE и сохранен как FAIL; snapshots/успешные POST/DELETE подтверждены отдельно. Submitted fixtures6/7 не пересдавались. +- [INV](https://github.com/PROCOLLAB-github/procollab/blob/79b4418d4bb891333b62c6025291af769d326b6e/docs/qa/program-solutions-invitations-20261025/README.md): runtime2ad4743/frontend0415dddd/C001.13;16 уникальных browser checks нескольких запусков и observed two-worker last-slot race. Все120 raw responses/18 request bodies заново проверены по C001.16. Начальные monitor/focus/layout harness failures не удалены. +- [NEG](https://github.com/PROCOLLAB-github/procollab/blob/79b4418d4bb891333b62c6025291af769d326b6e/docs/qa/program-solutions-negative-20261025/README.md): точные focus/minimum/tabs assertions отдельно от исправления unknown-submit alert; ранний частичный отчет не объявляется полностью зеленым. +- [ACCESS](https://github.com/PROCOLLAB-github/procollab/blob/79b4418d4bb891333b62c6025291af769d326b6e/docs/qa/program-solutions-access-20261025/README.md): current route lifecycle/privacy проверки; baseline обычный logout уже снимал view контейнером. + +Audit проверил hashes всех PART/INV helpers/screenshots/raw JSON, совпадение6 копий INV JSON и прежних core definitions. Изменение create_invitation ограничено добавлением role validation; остальные service/API definitions совпадают с INV runtime после учета этой конкретной строки. team/model files и6 frontend team/modal/shared-form/use-case files совпадают с соответствующими историческими refs. Это сопоставление прежних доказательств и новая HTTP validation проверка; нового полного mobile browser прохождения на current source не заявляем. + +Raw HTTP лежит в `Z:/Документы/PROCOLLAB-dev/design-artifacts/program-solutions-20261024/qa01-team-role`; helper/логи — `procollab-team-*` в Temp. Credentials/JWT в Git не включены. Нет merge/deploy/переключения реальных программ. D02/D03, submitted administrative policy, removal/detach/retention, competitive publication и общий первый выпуск остаются открытыми. diff --git a/docs/qa/program-solutions-team-acceptance-20261025/evidence.json b/docs/qa/program-solutions-team-acceptance-20261025/evidence.json new file mode 100644 index 00000000..9ccd99d0 --- /dev/null +++ b/docs/qa/program-solutions-team-acceptance-20261025/evidence.json @@ -0,0 +1,246 @@ +{ + "status": "PASS", + "audit": "Historical evidence reconciliation; not a new browser journey", + "c00": "1.16.0", + "schemas": 128, + "operations": 49, + "scenarios": 217, + "changedRequestSchemas": [ + "CreateInvitation", + "UpdateInvitation" + ], + "unchangedSchemas": 126, + "unchangedOperations": 49, + "changedOldScenarios": [ + "duplicate_invitation", + "max_team", + "team_frozen" + ], + "unchangedOldScenarios": 206, + "newScenarios": 8, + "sources": [ + { + "repository": "backend", + "file": "partner_programs/solution_invitations.py", + "sha256": "f345cc950fa034daead4d621d9b59a416952edff52a86daed3dfd8eccd5ef967", + "reference": "6db9050", + "unchangedDefinitions": 12, + "intentionalChanges": [ + "require_fields", + "require_role" + ] + }, + { + "repository": "backend", + "file": "partner_programs/solution_invitation_api.py", + "sha256": "097e16a5a1943426b6222ddad77ca0272336b82719aabd4928c6f5c768df20a9", + "reference": "6db9050", + "unchangedDefinitions": 12, + "intentionalChanges": [ + "CreateInvitationSerializer", + "InvitationFields" + ] + }, + { + "repository": "backend", + "file": "partner_programs/solution_team.py", + "sha256": "38b836a54d19dc6c2b1f8f9b02db8ba5f573a44bba08ad6d0aa0e542abb14c17", + "reference": "6db9050", + "unchanged": true + }, + { + "repository": "backend", + "file": "partner_programs/solution_models.py", + "sha256": "928fc7a89042dc5153c17df79b6a07a47904900138df1090de27d5b29f61d0d6", + "reference": "6db9050", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution-team.component.ts", + "sha256": "bb7b6e511c2a8ba0ed6ce4a3911cafd0441a69370e4a1ff3ba21c75da6f40e49", + "reference": "0415dddd", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/ui/pages/program/solution/solution-team.component.html", + "sha256": "b46c60bf48dd12a7105a9ede2637f8376d15360c54ae38f0761253954d501063", + "reference": "0415dddd", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/ui/widgets/project-invite/solution-member-invite-modal.component.ts", + "sha256": "2d15ee9745675ae9ba66e6413de83a225a74f906d1b1f9a3c459ef1c78e8c681", + "reference": "0415dddd", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.ts", + "sha256": "480c1ea83cfb7dc607ec552c009de23d024a62c301b4341e193067ab858e7e11", + "reference": "0415dddd", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/ui/widgets/project-invite/member-invite-form.component.html", + "sha256": "f66ad1f0b847cde0aed40f65fc0b3299d88238e58cbf381ba725d9a8c2aa652f", + "reference": "0415dddd", + "unchanged": true + }, + { + "repository": "frontend", + "file": "projects/social_platform/src/app/api/solution/use-cases/solution-team.use-cases.ts", + "sha256": "17a1e6146f697aa175fc78c1d1f4a8caad30a00bbb697911993913556d2537dd", + "reference": "0415dddd", + "unchanged": true + } + ], + "verifiedArtifactHashes": [ + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-participant-browser.cjs", + "sha256": "308563ef23f06dcade9b73b59d310e1be6be5305ca9673a1b32fce70896b1038" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-participant-verify.py", + "sha256": "b2a2c3658a82ea67c5bc17f95c89db52275da570698b6d5df866a57d94870ad8" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-local-build.cjs", + "sha256": "7d7b4d4c8e8cdd3e7ba6008b0bdad4f1551daf6c2a65768249ce4110d74d6c7a" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-confirmation-mobile.png", + "sha256": "9ae2f9b47c39105fc23897ba0eff61934bf33b06c8a70a1c05975057a71a687f" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-materials-desktop.png", + "sha256": "258d58f4be62fdc9e65b23142f4d71172d199469f20a7dd8a1b74c4994ec1731" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-materials-mobile.png", + "sha256": "1cdd076dc423d273a16daf59c00aea46128c3f283e0bd3a413b232b9b7a37e83" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-submitted-materials-mobile.png", + "sha256": "25e6d6550ce189afe29255fd4e057a85598d53c2b28ad38b40e5e6963898d18f" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-submitted-team-mobile.png", + "sha256": "e74d1e4dee34078fa2bad7ba1031a8e4bd7bfd5330d8ad1ae63c0cbff68ae2ef" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\case_championship-team-mobile.png", + "sha256": "4d10813040fdef13d2397123fa7d2c37a675c6d39fcb304d32302b95c5941a1d" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-confirmation-mobile.png", + "sha256": "03fa0e508aa5d68cc21429ce96dd527366ae4bac59ff9bb07ee03e52ad0f350b" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-materials-desktop.png", + "sha256": "acfdbd9a288219406a6c3198e1993e9b677b140b01144a2691cdbadf61eae948" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-materials-mobile.png", + "sha256": "d2a77a47c50907daa1f9469aac6059131f11712cae5e718cfa00b8511ec8d234" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-submitted-materials-mobile.png", + "sha256": "cd6351263b1ebfebfaef2ac387b89cd8c419d6c89ec97fc08426edc2e9bb4269" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-submitted-team-mobile.png", + "sha256": "37a205fa964ee7f6082cb936e82ac5578fa87c9f02973419927e4c2607f1a0cc" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-participant\\hackathon-team-mobile.png", + "sha256": "c2fbba39f4f0bd0a70fd3573fec334e0699ad33cbb36b1f52f2a10d206b8eb22" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-invitations-race.py", + "sha256": "180384db4b3c7010afd79f294fd34a1bc8b3d04c27f0fec3e513f9d62b801449" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-invitations-browser.cjs", + "sha256": "e6bebdbfd9416ceed32dc167b81c62f9c359caa67edb35653c7ead44f547ef14" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-invitations-verify.py", + "sha256": "9c1e0999c3dd626c7d48157bf40815c229445e395332d664418c3a3226e87617" + }, + { + "file": "C:\\Users\\evsee\\AppData\\Local\\Temp\\procollab-qa01-invitations-worker.py", + "sha256": "f3a64a1939c5719343abe58552910bc82ca5717b99e27bfe322c048aa4bfc0b3" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\browser-error.png", + "sha256": "bb558d5ef553760ba35b11a9bb7783f0b6731fc1374fba8e05536c9408509268" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\case_championship-final-team-1440.png", + "sha256": "91f1e4092f495332a84360b6b6ece26dbce0769cd042030dbb2379ff1bbfc6e7" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\case_championship-final-team-390.png", + "sha256": "07128d805a87a6453e3529517494637d88a11d8aaee903e125c6f55b02b62c1f" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\case_championship-long-role-1440.png", + "sha256": "12e03f669820a31697bc26969dedb523e03478f0c51b371bfd3e69124c744500" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\case_championship-long-role-390.png", + "sha256": "fde0422c9ad497d5ceba387f279078a97566d06b53ec81fb8db38c28133914b3" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\hackathon-final-team-1440.png", + "sha256": "e8097352a6b5df0fb49179a6b4a92c2fc7b5c8deb0bdd89e72c73ef5539d03e1" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\hackathon-final-team-390.png", + "sha256": "007c749d682d4af411b2392aeec5c74a588fd609c0b7ea940675bff04da66d96" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\hackathon-long-role-1440.png", + "sha256": "968169730d8cffd351d397a6abb603e73a03cc6ec510b661aa5eb0f5de94c1b0" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\hackathon-long-role-390.png", + "sha256": "9a3991d65164c63cb46c32b853533c9fea099c31310cba1a0d6552d6435d211a" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\transient-case_championship-long-role-1440.png", + "sha256": "55ebf7075603adabc794e1a77a5e2e8cb7cdabd78754f586693cb0c81df6ecad" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\transient-case_championship-long-role-390.png", + "sha256": "c964f4edee3a3afb2b37fd71ce25945a9a0f6378c0fd0587fe2c2ffbe6ddb941" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\transient-hackathon-long-role-1440.png", + "sha256": "a555248593cb01a5349fe62bd4449f7a0602e2beb96afe6a3aad96e08a5c7b68" + }, + { + "file": "Z:\\Документы\\PROCOLLAB-dev\\design-artifacts\\program-solutions-20261024\\qa01-invitations\\transient-hackathon-long-role-390.png", + "sha256": "71e4599481e79db11437f01c16bd10932e6880d44798a336ead79b8d864bde59" + } + ], + "invitationRawResponsesValidatedAgainstCurrentC00": 120, + "invitationRawRequestsValidatedAgainstCurrentC00": 18, + "invitationUniqueChecks": 16, + "partRuntime": { + "backend": "1724bca4c7cf7907955312d1f4c9e6ec44316fbd", + "frontend": "e57fb04ec93fdede611712014492a529cf9172b9", + "c00": "1.12.0" + }, + "invRuntime": { + "backend": "2ad4743", + "frontend": "0415dddd", + "c00": "1.13.0" + }, + "newFixtures": 0, + "domainWrites": 0 +} diff --git a/docs/qa/program-solutions-team-acceptance-20261025/http-evidence.json b/docs/qa/program-solutions-team-acceptance-20261025/http-evidence.json new file mode 100644 index 00000000..a4936c77 --- /dev/null +++ b/docs/qa/program-solutions-team-acceptance-20261025/http-evidence.json @@ -0,0 +1,55 @@ +{ + "status": "PASS", + "c00": "1.16.0", + "backendBase": "ef314c30e3491e15b8232413000597b3f9df1456", + "sources": { + "partner_programs/solution_invitations.py": "f345cc950fa034daead4d621d9b59a416952edff52a86daed3dfd8eccd5ef967", + "partner_programs/solution_invitation_api.py": "097e16a5a1943426b6222ddad77ca0272336b82719aabd4928c6f5c768df20a9", + "partner_programs/tests/test_solution_invitation_roles.py": "7050bdfff6dccd2d93252a75a719f1859b3a92055e0122dabaa420a4060d79a6", + "partner_programs/tests/test_solution_invitations.py": "1db7f48cf2495edf2c59b62cf2c9d95642ac0f3e8f1d3ce97fa16bdc83422ec3" + }, + "responses": 46, + "rejectedInvalidRequests": 38, + "checks": [ + { + "program": 30, + "solution": 45, + "key": "case_championship", + "invitation": 51, + "invalidCreate": 10, + "invalidPatch": 9, + "solutionAndInvitationReadbackUnchanged": true + }, + { + "program": 31, + "solution": 47, + "key": "hackathon", + "invitation": 52, + "invalidCreate": 10, + "invalidPatch": 9, + "solutionAndInvitationReadbackUnchanged": true + } + ], + "domainModels": [ + "partner_programs.PartnerProgram", + "partner_programs.PartnerProgramField", + "partner_programs.PartnerProgramUserProfile", + "partner_programs.ProgramSolution", + "partner_programs.ProgramSolutionAnswer", + "partner_programs.ProgramSolutionMember", + "partner_programs.SolutionInvitation", + "project_rates.SolutionScore", + "project_rates.SolutionExpertAssignment", + "project_rates.Criteria", + "users.Expert", + "files.UserFile", + "projects.Project", + "users.Expert_programs", + "partner_programs.PartnerProgram_managers" + ], + "domainBefore": "f15e4e8a179c7a4e3e046a89d4b244973da0aa7f024c789b4b0964eb41423d9b", + "domainAfter": "f15e4e8a179c7a4e3e046a89d4b244973da0aa7f024c789b4b0964eb41423d9b", + "domainMutations": 0, + "newFixtures": 0, + "projectCountUnchanged": true +} diff --git a/files/admin.py b/files/admin.py index 75d70239..39e14de1 100644 --- a/files/admin.py +++ b/files/admin.py @@ -1,6 +1,8 @@ import reprlib from django.contrib import admin +from django.db import transaction +from django.core.exceptions import PermissionDenied from django.forms import ModelForm, FileField from django.db.models import QuerySet @@ -19,6 +21,19 @@ class Meta: @admin.register(UserFile) class UserFileAdmin(admin.ModelAdmin): form = UserFileForm + readonly_fields = ("detected_mime_type",) + + def get_deleted_objects(self, objs, request): + from partner_programs.solution_files import used_by_submitted_solution + + deleted, counts, permissions_needed, protected = super().get_deleted_objects( + objs, request + ) + for obj in objs: + if used_by_submitted_solution(obj.pk): + protected.append(f"Файл сохраненного решения: {obj}") + return deleted, counts, permissions_needed, protected + list_display = ( "short_link", "filename", @@ -72,14 +87,27 @@ def save_model(self, request, obj, form, change): obj.size = info.size obj.extension = info.extension obj.mime_type = info.mime_type + obj.detected_mime_type = info.detected_mime_type super().save_model(request, obj, form, change) + @transaction.atomic def delete_model(self, request, obj): + from partner_programs.solution_files import used_by_submitted_solution + + obj = UserFile.objects.select_for_update().get(pk=obj.pk) + if used_by_submitted_solution(obj.pk): + raise PermissionDenied("Файл используется в сохраненном решении.") self.cdn.delete(obj.link) obj.delete() + @transaction.atomic def delete_queryset(self, request, queryset): - for obj in queryset: + from partner_programs.solution_files import used_by_submitted_solution + + rows = list(queryset.select_for_update().order_by("pk")) + if any(used_by_submitted_solution(obj.pk) for obj in rows): + raise PermissionDenied("Файл используется в сохраненном решении.") + for obj in rows: self.cdn.delete(obj.link) queryset.delete() diff --git a/files/content.py b/files/content.py new file mode 100644 index 00000000..e5d1a94c --- /dev/null +++ b/files/content.py @@ -0,0 +1,77 @@ +"""Определение типа по содержимому; не меняет допустимые типы общего upload.""" + +import codecs +import zipfile +from io import BytesIO + +from PIL import Image, UnidentifiedImageError + + +def detect_content_type(stream): + # The existing WebP encoder returns a cffi buffer rather than a seekable file. + if not hasattr(stream, "seek"): + stream = BytesIO(bytes(stream)) + position = stream.tell() + try: + stream.seek(0) + header = stream.read(65536) + if header.startswith(b"%PDF-"): + return "application/pdf" + stream.seek(0) + if header.startswith((b"PK\x03\x04", b"PK\x05\x06")) and zipfile.is_zipfile( + stream + ): + stream.seek(0) + with zipfile.ZipFile(stream) as archive: + names = set(archive.namelist()) + if "[Content_Types].xml" in names: + for name, mime in ( + ( + "word/document.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ( + "ppt/presentation.xml", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ( + "xl/workbook.xml", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ): + if name in names: + return mime + return "application/zip" + stream.seek(0) + try: + with Image.open(stream) as image: + kind = image.format + image.verify() + return Image.MIME.get(kind, "application/octet-stream") + except (UnidentifiedImageError, OSError, ValueError): + pass + if header.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"): + return "application/x-ole-storage" + if len(header) > 12 and header[4:8] == b"ftyp": + return "video/mp4" + if header.startswith(b"ID3") or header[:2] in ( + b"\xff\xfb", + b"\xff\xf3", + b"\xff\xf2", + ): + return "audio/mpeg" + if header.startswith(b"OggS"): + return "application/ogg" + if header.startswith(b"RIFF") and header[8:12] == b"WAVE": + return "audio/wav" + if b"\x00" not in header: + try: + codecs.getincrementaldecoder("utf-8")().decode(header, final=False) + return "text/plain" + except UnicodeDecodeError: + pass + return "application/octet-stream" + except (zipfile.BadZipFile, Image.DecompressionBombError, OSError): + return "application/octet-stream" + finally: + stream.seek(position) diff --git a/files/migrations/0008_userfile_detected_mime_type.py b/files/migrations/0008_userfile_detected_mime_type.py new file mode 100644 index 00000000..1ce45337 --- /dev/null +++ b/files/migrations/0008_userfile_detected_mime_type.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("files", "0007_auto_20230929_1727")] + + operations = [ + migrations.AddField( + model_name="userfile", + name="detected_mime_type", + field=models.CharField(blank=True, default="", max_length=256), + ), + ] diff --git a/files/models.py b/files/models.py index ea7393cf..2371fe53 100644 --- a/files/models.py +++ b/files/models.py @@ -27,6 +27,7 @@ class UserFile(models.Model): name = models.TextField(blank=False, default="file") extension = models.TextField(blank=True, default="") mime_type = models.CharField(max_length=256, default="") + detected_mime_type = models.CharField(max_length=256, default="", blank=True) size = models.PositiveBigIntegerField(null=False, blank=True, default=1) def __str__(self): diff --git a/files/service.py b/files/service.py index fafe5698..1304af6c 100644 --- a/files/service.py +++ b/files/service.py @@ -8,6 +8,7 @@ from requests import Response from files.constants import SUPPORTED_IMAGES_TYPES +from files.content import detect_content_type from files.exceptions import SelectelUploadError from files.helpers import convert_image_to_webp from files.typings import FileInfo @@ -18,7 +19,11 @@ class File: def __init__( - self, file: TemporaryUploadedFile | InMemoryUploadedFile, quality: int = 70 + self, + file: TemporaryUploadedFile | InMemoryUploadedFile, + quality: int = 70, + *, + preserve_original: bool = False, ): self.size = file.size self.name = File._get_name(file) @@ -27,12 +32,15 @@ def __init__( self.content_type = file.content_type # we can compress given type of image - if self.content_type in SUPPORTED_IMAGES_TYPES: + if self.content_type in SUPPORTED_IMAGES_TYPES and not preserve_original: webp_image = convert_image_to_webp(file, quality) self.buffer = webp_image.buffer() self.size = webp_image.size self.content_type = "image/webp" self.extension = "webp" + self.detected_mime_type = ( + detect_content_type(self.buffer) if self.size <= 104857600 else "" + ) @staticmethod def _get_name(file) -> str: @@ -71,6 +79,7 @@ def upload(self, file: File, user: User) -> FileInfo: extension=file.extension, mime_type=file.content_type, size=file.size, + detected_mime_type=file.detected_mime_type, ) def _upload(self, file: File, user: User) -> str: @@ -141,5 +150,9 @@ def upload( file: TemporaryUploadedFile | InMemoryUploadedFile, user: User, quality: int = 70, + *, + preserve_original: bool = False, ) -> FileInfo: - return self.storage.upload(File(file, quality), user) + return self.storage.upload( + File(file, quality, preserve_original=preserve_original), user + ) diff --git a/files/typings.py b/files/typings.py index 84afb5c1..28a5e786 100644 --- a/files/typings.py +++ b/files/typings.py @@ -11,3 +11,4 @@ class FileInfo: name: str extension: str mime_type: str + detected_mime_type: str = "" diff --git a/files/views.py b/files/views.py index cf309c5e..776ab8a5 100644 --- a/files/views.py +++ b/files/views.py @@ -22,7 +22,32 @@ def post(self, request): Creates a UserFile object and uploads the file to Selectel """ - info = self.cdn.upload(request.FILES["file"], request.user) + if "file" not in request.FILES: + return Response( + { + "code": "validation_error", + "detail": "Выберите файл.", + "errors": [], + "meta": {}, + }, + status=400, + ) + original = request.query_params.get("preserve_original", "false") + if original not in ("true", "false"): + return Response( + { + "code": "validation_error", + "detail": "preserve_original должен быть true или false.", + "errors": [], + "meta": {}, + }, + status=400, + ) + info = ( + self.cdn.upload(request.FILES["file"], request.user, preserve_original=True) + if original == "true" + else self.cdn.upload(request.FILES["file"], request.user) + ) UserFile.objects.create( user=request.user, link=info.url, @@ -30,9 +55,11 @@ def post(self, request): size=info.size, extension=info.extension, mime_type=info.mime_type, + detected_mime_type=info.detected_mime_type, ) return Response({"url": info.url}, status=status.HTTP_201_CREATED) + @transaction.atomic def delete(self, request, *args, **kwargs): """deletes the file (only if the request is sent by the user who owns it!) The link has to be specified in the JSON body, not in the URL arguments. @@ -47,9 +74,16 @@ def delete(self, request, *args, **kwargs): }, status=status.HTTP_400_BAD_REQUEST, ) - instance = get_object_or_404(self.get_queryset(), link=link) + instance = get_object_or_404(self.get_queryset().select_for_update(), link=link) if instance.user != request.user: return Response(status=status.HTTP_403_FORBIDDEN) + from partner_programs.solution_files import protect_solution_file + from partner_programs.solution_errors import SolutionError + + try: + protect_solution_file(instance) + except SolutionError as error: + return Response(error.payload, status=error.status_code) self.cdn.delete(instance.link) # delete the file via api instance.delete() # delete the UserFile object return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 66639488..f4a49b50 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -4,6 +4,7 @@ import tablib from django import forms from django.contrib import admin +from . import solution_admin # noqa: F401 from projects.team_admin import TeamValidationAdminMixin from django.db.models import QuerySet from django.http import HttpRequest @@ -343,7 +344,12 @@ def get_deleted_objects(self, objs, request): objs, request ) for obj in objs: - if case_field_has_values(obj): + from partner_programs.solution_forms import answer_count + + if case_field_has_values(obj) or ( + obj.partner_program.solution_storage_mode == "solution" + and answer_count(obj) + ): protected.append(f"Используемое поле кейса: {obj}") return deleted, counts, permissions_needed, protected @@ -365,7 +371,10 @@ class ProgramRegistrationAdmin(admin.ModelAdmin): list_display = ("program", "external_status", "requested_at", "published_at") list_filter = ("external_status",) search_fields = ( - "program__name", "external_url", "connection_website_url", "connection_contact" + "program__name", + "external_url", + "connection_website_url", + "connection_contact", ) readonly_fields = ( "program", diff --git a/partner_programs/apps.py b/partner_programs/apps.py index 1db6f0a8..04acbf70 100644 --- a/partner_programs/apps.py +++ b/partner_programs/apps.py @@ -5,3 +5,7 @@ class PartnerProgramsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "partner_programs" verbose_name = "Партнерские программы" + + def ready(self): + from partner_programs import solution_file_signals # noqa: F401 + from partner_programs import solution_lifecycle_signals # noqa: F401 diff --git a/partner_programs/certificate_models.py b/partner_programs/certificate_models.py index d1409756..04dbd058 100644 --- a/partner_programs/certificate_models.py +++ b/partner_programs/certificate_models.py @@ -53,6 +53,13 @@ class ProgramCertificate(models.Model): program = models.ForeignKey("PartnerProgram", on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) batch = models.ForeignKey(ProgramCertificateBatch, on_delete=models.RESTRICT) + rendered_batch = models.ForeignKey( + ProgramCertificateBatch, + null=True, + blank=True, + on_delete=models.RESTRICT, + related_name="rendered_certificates", + ) name = models.CharField(max_length=500) email = models.EmailField() pdf = models.BinaryField(null=True) diff --git a/partner_programs/forms.py b/partner_programs/forms.py index 1b5fd163..0a40a95c 100644 --- a/partner_programs/forms.py +++ b/partner_programs/forms.py @@ -16,7 +16,13 @@ class ProgramFieldInlineFormSet(BaseInlineFormSet): def clean(self): super().clean() for form in self.deleted_forms: - if case_field_has_values(form.instance): + from partner_programs.solution_forms import answer_count + + if case_field_has_values(form.instance) or ( + form.instance.pk + and form.instance.partner_program.solution_storage_mode == "solution" + and answer_count(form.instance) + ): raise ValidationError( "Нельзя удалить системное поле с выбранными кейсами." ) diff --git a/partner_programs/management/__init__.py b/partner_programs/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/partner_programs/management/commands/__init__.py b/partner_programs/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/partner_programs/management/commands/set_solution_creation.py b/partner_programs/management/commands/set_solution_creation.py new file mode 100644 index 00000000..7db59883 --- /dev/null +++ b/partner_programs/management/commands/set_solution_creation.py @@ -0,0 +1,47 @@ +"""Отдельный rollout/rollback только создания, без переключения storage mode.""" + +import json + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from partner_programs.models import PartnerProgram +from partner_programs.solution_errors import MAX_ID + + +class Command(BaseCommand): + help = "Предпросмотр или изменение создания решений одной программы." + + def add_arguments(self, parser): + parser.add_argument("--program-id", type=int, required=True) + action = parser.add_mutually_exclusive_group(required=True) + action.add_argument("--enable", action="store_true") + action.add_argument("--disable", action="store_true") + parser.add_argument("--apply", action="store_true") + + def handle(self, *args, **options): + program_id = options["program_id"] + if not 0 < program_id <= MAX_ID: + raise CommandError("Укажите допустимый ID программы.") + with transaction.atomic(): + program = ( + PartnerProgram.objects.select_for_update().filter(pk=program_id).first() + ) + if program is None: + raise CommandError("Программа не найдена.") + enabled = options["enable"] + if enabled and program.solution_storage_mode != "solution": + raise CommandError("Сначала настройте режим решения выбранной программы.") + previous = program.solution_creation_enabled + if options["apply"] and previous != enabled: + program.solution_creation_enabled = enabled + program.save(update_fields=["solution_creation_enabled"]) + receipt = dict( + program_id=program.pk, + storage_mode=program.solution_storage_mode, + previous_enabled=previous, + requested_enabled=enabled, + applied=options["apply"], + changed=options["apply"] and previous != enabled, + ) + self.stdout.write(json.dumps(receipt, ensure_ascii=False)) diff --git a/partner_programs/migrations/0031_programcertificate_rendered_batch.py b/partner_programs/migrations/0031_programcertificate_rendered_batch.py new file mode 100644 index 00000000..11114878 --- /dev/null +++ b/partner_programs/migrations/0031_programcertificate_rendered_batch.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +def remember_rendered_batch(apps, schema_editor): + certificate = apps.get_model("partner_programs", "ProgramCertificate") + certificate.objects.using(schema_editor.connection.alias).filter( + pdf__isnull=False, rendered_batch__isnull=True + ).update(rendered_batch_id=models.F("batch_id")) + + +class Migration(migrations.Migration): + dependencies = [("partner_programs", "0030_program_solution_foundation")] + + operations = [ + migrations.AddField( + model_name="programcertificate", + name="rendered_batch", + field=models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="rendered_certificates", + to="partner_programs.programcertificatebatch", + ), + ), + migrations.RunPython(remember_rendered_batch, migrations.RunPython.noop), + ] diff --git a/partner_programs/migrations/0031_solution_field_options.py b/partner_programs/migrations/0031_solution_field_options.py new file mode 100644 index 00000000..8f70c6cd --- /dev/null +++ b/partner_programs/migrations/0031_solution_field_options.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("partner_programs", "0030_program_solution_foundation")] + + operations = [ + migrations.AddField( + model_name="partnerprogramfield", + name="solution_options", + field=models.JSONField(blank=True, default=None, null=True), + ), + ] diff --git a/partner_programs/migrations/0032_alter_programsolution_options.py b/partner_programs/migrations/0032_alter_programsolution_options.py new file mode 100644 index 00000000..79991755 --- /dev/null +++ b/partner_programs/migrations/0032_alter_programsolution_options.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.11 on 2026-10-24 20:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0031_solution_field_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="programsolution", + options={ + "ordering": ["created_at", "pk"], + "verbose_name": "Решение программы", + "verbose_name_plural": "Решения программ", + }, + ), + ] diff --git a/partner_programs/migrations/0033_solution_publication.py b/partner_programs/migrations/0033_solution_publication.py new file mode 100644 index 00000000..dff3284f --- /dev/null +++ b/partner_programs/migrations/0033_solution_publication.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2026-10-25 02:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0032_alter_programsolution_options"), + ] + + operations = [ + migrations.AddField( + model_name="programsolution", + name="published_at", + field=models.DateTimeField( + blank=True, editable=False, null=True, verbose_name="Дата публикации" + ), + ), + migrations.AddIndex( + model_name="programsolution", + index=models.Index( + condition=models.Q(("published_at__isnull", False)), + fields=["program", "published_at", "id"], + name="solution_publication_idx", + ), + ), + ] diff --git a/partner_programs/migrations/0034_solution_creation_switch.py b/partner_programs/migrations/0034_solution_creation_switch.py new file mode 100644 index 00000000..f9c7098b --- /dev/null +++ b/partner_programs/migrations/0034_solution_creation_switch.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2026-10-25 04:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0033_solution_publication"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="solution_creation_enabled", + field=models.BooleanField( + default=False, + editable=False, + verbose_name="Создание новых решений включено", + ), + ), + ] diff --git a/partner_programs/migrations/0035_solution_administrative_history.py b/partner_programs/migrations/0035_solution_administrative_history.py new file mode 100644 index 00000000..426121e2 --- /dev/null +++ b/partner_programs/migrations/0035_solution_administrative_history.py @@ -0,0 +1,111 @@ +# Generated by Django 4.2.11 on 2026-10-25 12:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.expressions + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0034_solution_creation_switch"), + ] + + operations = [ + migrations.CreateModel( + name="SolutionAdministrativeChange", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("actor_name", models.CharField(max_length=512)), + ( + "action", + models.CharField( + choices=[("change_case", "Смена кейса")], max_length=32 + ), + ), + ("before_version", models.PositiveBigIntegerField()), + ("after_version", models.PositiveBigIntegerField()), + ("case_field_id", models.PositiveBigIntegerField()), + ("previous_case", models.TextField(null=True)), + ("case_name", models.CharField(max_length=1000)), + ("removed_assignment_ids", models.JSONField(default=list)), + ("score_count", models.PositiveIntegerField(default=0)), + ("was_submitted", models.BooleanField()), + ("before_snapshot", models.JSONField(null=True)), + ("after_snapshot", models.JSONField(null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "actor", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "solution", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="administrative_changes", + to="partner_programs.programsolution", + ), + ), + ], + options={ + "verbose_name": "Административное изменение решения", + "verbose_name_plural": "История административных изменений решений", + "ordering": ["-created_at", "-pk"], + }, + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.UniqueConstraint( + fields=("solution", "after_version"), + name="unique_solution_admin_revision", + ), + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.CheckConstraint( + check=models.Q( + ("before_version__gte", 1), + ( + "after_version", + django.db.models.expressions.CombinedExpression( + models.F("before_version"), "+", models.Value(1) + ), + ), + ), + name="solution_admin_version_step", + ), + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("after_snapshot__isnull", False), + ("before_snapshot__isnull", False), + ("was_submitted", True), + ), + models.Q( + ("after_snapshot__isnull", True), + ("before_snapshot__isnull", True), + ("was_submitted", False), + ), + _connector="OR", + ), + name="solution_admin_snapshots_consistent", + ), + ), + ] diff --git a/partner_programs/migrations/0036_solution_member_admin_history.py b/partner_programs/migrations/0036_solution_member_admin_history.py new file mode 100644 index 00000000..f652e084 --- /dev/null +++ b/partner_programs/migrations/0036_solution_member_admin_history.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.11 on 2026-10-25 14:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0035_solution_administrative_history"), + ] + + operations = [ + migrations.AddField( + model_name="solutionadministrativechange", + name="removed_member", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="revoked_invitation_ids", + field=models.JSONField(default=list), + ), + migrations.AlterField( + model_name="solutionadministrativechange", + name="action", + field=models.CharField( + choices=[ + ("change_case", "Смена кейса"), + ("remove_member", "Исключение участника"), + ], + max_length=32, + ), + ), + migrations.AlterField( + model_name="solutionadministrativechange", + name="case_field_id", + field=models.PositiveBigIntegerField(null=True), + ), + migrations.AlterField( + model_name="solutionadministrativechange", + name="case_name", + field=models.CharField(max_length=1000, null=True), + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("action", "change_case"), + ("case_field_id__isnull", False), + ("case_name__isnull", False), + ("removed_member__isnull", True), + ), + models.Q( + ("action", "remove_member"), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("removed_member__isnull", False), + ), + _connector="OR", + ), + name="solution_admin_action_consistent", + ), + ), + ] diff --git a/partner_programs/migrations/0037_solution_archive_retention.py b/partner_programs/migrations/0037_solution_archive_retention.py new file mode 100644 index 00000000..d6b4b81e --- /dev/null +++ b/partner_programs/migrations/0037_solution_archive_retention.py @@ -0,0 +1,170 @@ +# Generated by Django 4.2.11 on 2026-10-25 14:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0036_solution_member_admin_history"), + ] + + operations = [ + migrations.AlterModelOptions( + name="programsolution", + options={ + "base_manager_name": "all_objects", + "default_manager_name": "objects", + "ordering": ["created_at", "pk"], + "verbose_name": "Решение программы", + "verbose_name_plural": "Решения программ", + }, + ), + migrations.AlterModelManagers( + name="programsolution", + managers=[ + ("objects", django.db.models.manager.Manager()), + ("all_objects", django.db.models.manager.Manager()), + ], + ), + migrations.RemoveConstraint( + model_name="programsolution", + name="unique_solution_program_captain", + ), + migrations.RemoveConstraint( + model_name="solutionadministrativechange", + name="solution_admin_action_consistent", + ), + migrations.AddField( + model_name="programsolution", + name="archive_reason", + field=models.CharField( + choices=[ + ("detached", "Снятие заявки"), + ("registration_removed", "Удаление регистрации"), + ], + editable=False, + max_length=32, + null=True, + ), + ), + migrations.AddField( + model_name="programsolution", + name="archive_snapshot", + field=models.JSONField(editable=False, null=True), + ), + migrations.AddField( + model_name="programsolution", + name="archived_at", + field=models.DateTimeField(editable=False, null=True), + ), + migrations.AddField( + model_name="programsolution", + name="archived_by", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="archived_program_solutions", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="archive_reason", + field=models.CharField(max_length=32, null=True), + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="archive_snapshot", + field=models.JSONField(null=True), + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="archived_at", + field=models.DateTimeField(null=True), + ), + migrations.AlterField( + model_name="solutionadministrativechange", + name="action", + field=models.CharField( + choices=[ + ("change_case", "Смена кейса"), + ("remove_member", "Исключение участника"), + ("archive", "Снятие заявки"), + ], + max_length=32, + ), + ), + migrations.AddConstraint( + model_name="programsolution", + constraint=models.UniqueConstraint( + condition=models.Q(("archived_at__isnull", True)), + fields=("program", "captain"), + name="unique_active_solution_captain", + ), + ), + migrations.AddConstraint( + model_name="programsolution", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ), + models.Q( + ("archive_reason__in", ["detached", "registration_removed"]), + ("archive_reason__isnull", False), + ("archive_snapshot__isnull", False), + ("archived_at__isnull", False), + ), + _connector="OR", + ), + name="solution_archive_consistent", + ), + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("action", "change_case"), + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ("case_field_id__isnull", False), + ("case_name__isnull", False), + ("removed_member__isnull", True), + ), + models.Q( + ("action", "remove_member"), + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("removed_member__isnull", False), + ), + models.Q( + ("action", "archive"), + ("archive_reason__in", ["detached", "registration_removed"]), + ("archive_reason__isnull", False), + ("archive_snapshot__isnull", False), + ("archived_at__isnull", False), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("removed_member__isnull", True), + ), + _connector="OR", + ), + name="solution_admin_action_consistent", + ), + ), + ] diff --git a/partner_programs/migrations/0038_solution_registration_removal_audit.py b/partner_programs/migrations/0038_solution_registration_removal_audit.py new file mode 100644 index 00000000..f78bf1d1 --- /dev/null +++ b/partner_programs/migrations/0038_solution_registration_removal_audit.py @@ -0,0 +1,152 @@ +# Generated by Django 4.2.11 on 2026-10-25 16:08 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0037_solution_archive_retention"), + ] + + operations = [ + migrations.CreateModel( + name="SolutionRegistrationRemoval", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("actor_name", models.CharField(max_length=512)), + ("registrations", models.JSONField()), + ("deleted_count", models.PositiveIntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "verbose_name": "Удаление регистраций решений", + "verbose_name_plural": "Аудит удаления регистраций решений", + "ordering": ["-created_at", "-pk"], + }, + ), + migrations.RemoveConstraint( + model_name="solutionadministrativechange", + name="solution_admin_action_consistent", + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="removed_members", + field=models.JSONField(null=True), + ), + migrations.AlterField( + model_name="solutionadministrativechange", + name="action", + field=models.CharField( + choices=[ + ("change_case", "Смена кейса"), + ("remove_member", "Исключение участника"), + ("archive", "Снятие заявки"), + ("remove_registrations", "Удаление регистраций"), + ], + max_length=32, + ), + ), + migrations.AddField( + model_name="solutionregistrationremoval", + name="actor", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="solutionregistrationremoval", + name="program", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="solution_registration_removals", + to="partner_programs.partnerprogram", + ), + ), + migrations.AddField( + model_name="solutionadministrativechange", + name="registration_removal", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="changes", + to="partner_programs.solutionregistrationremoval", + ), + ), + migrations.AddConstraint( + model_name="solutionadministrativechange", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("action", "change_case"), + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ("case_field_id__isnull", False), + ("case_name__isnull", False), + ("registration_removal__isnull", True), + ("removed_member__isnull", True), + ("removed_members__isnull", True), + ), + models.Q( + ("action", "remove_member"), + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("registration_removal__isnull", True), + ("removed_member__isnull", False), + ("removed_members__isnull", True), + ), + models.Q( + ("action", "archive"), + ("archive_reason__in", ["detached", "registration_removed"]), + ("archive_reason__isnull", False), + ("archive_snapshot__isnull", False), + ("archived_at__isnull", False), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("removed_member__isnull", True), + ("removed_members__isnull", True), + ), + models.Q( + ("action", "remove_registrations"), + ("archive_reason__isnull", True), + ("archive_snapshot__isnull", True), + ("archived_at__isnull", True), + ("case_field_id__isnull", True), + ("case_name__isnull", True), + ("previous_case__isnull", True), + ("registration_removal__isnull", False), + ("removed_member__isnull", True), + ("removed_members__isnull", False), + ), + _connector="OR", + ), + name="solution_admin_action_consistent", + ), + ), + migrations.AddConstraint( + model_name="solutionregistrationremoval", + constraint=models.CheckConstraint( + check=models.Q(("deleted_count__gte", 1)), + name="solution_registration_removal_positive", + ), + ), + ] diff --git a/partner_programs/migrations/0039_merge_solution_certificate_branches.py b/partner_programs/migrations/0039_merge_solution_certificate_branches.py new file mode 100644 index 00000000..a082e7c1 --- /dev/null +++ b/partner_programs/migrations/0039_merge_solution_certificate_branches.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.11 on 2026-10-25 19:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0031_programcertificate_rendered_batch"), + ("partner_programs", "0038_solution_registration_removal_audit"), + ] + + operations = [] diff --git a/partner_programs/models.py b/partner_programs/models.py index d9ff7263..4c439fef 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -124,6 +124,11 @@ class PartnerProgram(models.Model): default="legacy_project", verbose_name="Технический режим заявки", ) + solution_creation_enabled = models.BooleanField( + default=False, + editable=False, + verbose_name="Создание новых решений включено", + ) solution_template = models.CharField( max_length=24, choices=[("case_championship", "Кейс-чемпионат"), ("hackathon", "Хакатон")], @@ -262,6 +267,7 @@ class PartnerProgram(models.Model): def clean(self): super().clean() + self.validate_solution_template_change() for field in ("legacy_team_min_size", "legacy_team_max_size"): value = getattr(self, field) if value is not None and value < 1: @@ -277,6 +283,36 @@ def clean(self): {"legacy_team_max_size": "Максимум не может быть меньше минимума."} ) + def validate_solution_template_change(self, previous=None): + if not self.pk: + return + if previous is None: + previous = type(self).objects.filter(pk=self.pk).first() + if previous and previous.solution_template != self.solution_template: + from partner_programs.solution_forms import has_answers + + if has_answers(self): + raise ValidationError( + {"solution_template": "После появления ответов шаблон нельзя менять."} + ) + + def save(self, *args, **kwargs): + fields = kwargs.get("update_fields") + if self.pk and (fields is None or "solution_template" in fields): + with transaction.atomic(): + previous = ( + type(self).objects.select_for_update().filter(pk=self.pk).first() + ) + self.validate_solution_template_change(previous) + # Обычный save устаревшей карточки не отменяет операторский откат. + # Служебный запуск меняется явным update_fields под program lock. + if previous and ( + fields is None or "solution_creation_enabled" not in fields + ): + self.solution_creation_enabled = previous.solution_creation_enabled + return super().save(*args, **kwargs) + return super().save(*args, **kwargs) + def is_manager(self, user: User) -> bool: """ Возвращает True, если пользователь — менеджер этой программы. @@ -567,6 +603,7 @@ class PartnerProgramField(models.Model): options = models.TextField( blank=True, null=True, help_text="Опции через | (для select/radio/checkbox)" ) + solution_options = models.JSONField(null=True, blank=True, default=None) def __str__(self): return f"{self.partner_program.name} — {self.label} ({self.name})" @@ -577,6 +614,8 @@ class Meta: unique_together = ("partner_program", "name") def get_options_list(self) -> list[str]: + if self.solution_options is not None: + return list(self.solution_options) opts = self.options.split("|") if self.options else [] return [opt.strip() for opt in opts if opt.strip()] @@ -588,19 +627,87 @@ def clean(self): ) validate_program_case_configuration(self) + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + program = PartnerProgram.objects.filter(pk=self.partner_program_id).first() + if self.partner_program_id and ( + program + and program.solution_storage_mode == "solution" + or previous + and previous.solution_answers.exists() + ): + from partner_programs.solution_form_api import FieldWriteSerializer + from partner_programs.solution_forms import check_evolution, answer_count + from partner_programs.solution_schema import field_data, validate_definition + from partner_programs.solution_schema import VALIDATION_DEFAULTS + from partner_programs.solution_errors import SolutionError + + if self.solution_options is not None and not isinstance( + self.solution_options, list + ): + raise ValidationError({"solution_options": "Ожидается список вариантов."}) + if not isinstance(self.validation, dict) or set(self.validation) - set( + VALIDATION_DEFAULTS + ): + raise ValidationError( + {"validation": "Ожидается объект поддерживаемых ограничений."} + ) + candidate = field_data(self) + serializer = FieldWriteSerializer(data=candidate) + if not serializer.is_valid(): + raise ValidationError({"validation": str(serializer.errors)}) + try: + validate_definition(candidate) + purpose = candidate["purpose"] + if purpose != "other" and program: + others = program.fields.exclude(pk=self.pk) + if purpose == "case": + from django.db.models import Q + + duplicate = others.filter( + Q(name="case") | Q(purpose=purpose) + ).exists() + else: + duplicate = others.filter(purpose=purpose).exists() + if duplicate: + raise ValidationError( + {"purpose": "Такое назначение уже есть в форме."} + ) + if previous: + if ( + previous.partner_program_id != self.partner_program_id + and answer_count(previous) + ): + raise ValidationError( + "Нельзя переносить поле с ответами в другую программу." + ) + check_evolution(previous, candidate) + except SolutionError as error: + raise ValidationError(error.payload["detail"]) from error def save(self, *args, **kwargs): """Serialize case definition edits with case value writes; no schema changes.""" from partner_programs.services.case_fields import is_program_case_field with transaction.atomic(): + program = ( + PartnerProgram.objects.select_for_update() + .filter(pk=self.partner_program_id) + .first() + ) + if program is None: + raise ValidationError({"partner_program": "Программа не найдена."}) + self.partner_program = program previous = ( type(self).objects.select_for_update().filter(pk=self.pk).first() if self.pk else None ) - if is_program_case_field(self) or ( - previous and is_program_case_field(previous) + if ( + self.partner_program.solution_storage_mode == "solution" + or is_program_case_field(self) + or (previous and is_program_case_field(previous)) + or previous + and previous.solution_answers.exists() ): self.full_clean() return super().save(*args, **kwargs) diff --git a/partner_programs/selectors.py b/partner_programs/selectors.py index 5829a8bd..a98ae41e 100644 --- a/partner_programs/selectors.py +++ b/partner_programs/selectors.py @@ -98,6 +98,18 @@ def program_participants(program_id: int): def program_participants_without_project(program_id: int): + from partner_programs.solution_participants import ( + uses_solutions, + participant_profiles, + ) + + if uses_solutions(program_id): + profiles = participant_profiles(program_id, _participant_profiles(program_id)) + return User.objects.filter( + id__in=profiles.filter( + is_project_leader=False, is_project_collaborator=False + ).values("user_id") + ).distinct() profiles = _participant_profiles(program_id) leader_exists = Exists( PartnerProgramProject.objects.filter( @@ -120,6 +132,21 @@ def program_participants_without_project(program_id: int): def program_participants_without_project_registered_on(program_id: int, target_date): + from partner_programs.solution_participants import ( + uses_solutions, + participant_profiles, + ) + + if uses_solutions(program_id): + profiles = participant_profiles( + program_id, + _participant_profiles(program_id).filter(datetime_created__date=target_date), + ) + return User.objects.filter( + id__in=profiles.filter( + is_project_leader=False, is_project_collaborator=False + ).values("user_id") + ).distinct() profiles = _participant_profiles(program_id).filter( datetime_created__date=target_date ) @@ -144,9 +171,23 @@ def program_participants_without_project_registered_on(program_id: int, target_d def program_participants_with_unsubmitted_project(program_id: int): - participant_ids = _participant_profiles(program_id).values_list( - "user_id", flat=True - ) + from partner_programs.solution_participants import uses_solutions + + if uses_solutions(program_id): + from partner_programs.solution_models import ProgramSolution + + solutions = ProgramSolution.objects.filter(program_id=program_id, status="draft") + return ( + User.objects.filter( + id__in=_participant_profiles(program_id).values("user_id") + ) + .filter( + Q(id__in=solutions.values("captain_id")) + | Q(id__in=solutions.values("members__user_id")) + ) + .distinct() + ) + participant_ids = _participant_profiles(program_id).values_list("user_id", flat=True) leader_ids = PartnerProgramProject.objects.filter( partner_program_id=program_id, submitted=False ).values_list("project__leader_id", flat=True) @@ -154,9 +195,11 @@ def program_participants_with_unsubmitted_project(program_id: int): project__program_links__partner_program_id=program_id, project__program_links__submitted=False, ).values_list("user_id", flat=True) - return User.objects.filter(id__in=participant_ids).filter( - Q(id__in=leader_ids) | Q(id__in=collab_ids) - ).distinct() + return ( + User.objects.filter(id__in=participant_ids) + .filter(Q(id__in=leader_ids) | Q(id__in=collab_ids)) + .distinct() + ) def program_participants_with_inactive_account(program_id: int, program_started_at): @@ -167,7 +210,9 @@ def program_participants_with_inactive_account(program_id: int, program_started_ def program_participants_with_inactive_account_registered_on( program_id: int, target_date, program_started_at ): - participant_ids = _participant_profiles(program_id).filter( - datetime_created__date=target_date - ).values_list("user_id", flat=True) + participant_ids = ( + _participant_profiles(program_id) + .filter(datetime_created__date=target_date) + .values_list("user_id", flat=True) + ) return _inactive_program_users(participant_ids, program_started_at) diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 8f6bd758..5d4f800f 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -112,6 +112,9 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): materials = serializers.SerializerMethodField() is_user_manager = serializers.SerializerMethodField() is_user_expert = serializers.SerializerMethodField() + solution_storage_mode = serializers.ChoiceField( + choices=("legacy_project", "solution"), read_only=True + ) pending_expert_invitation_id = serializers.SerializerMethodField() courses = serializers.SerializerMethodField() registration_managed = serializers.SerializerMethodField() @@ -250,6 +253,7 @@ class Meta: "is_user_manager", "welcome_acknowledged_at", "is_user_expert", + "solution_storage_mode", "pending_expert_invitation_id", "courses", ) @@ -282,6 +286,7 @@ class Meta: "legacy_team_max_size", "is_user_manager", "is_user_expert", + "solution_storage_mode", "pending_expert_invitation_id", "courses", ) diff --git a/partner_programs/serializers/role_analytics.py b/partner_programs/serializers/role_analytics.py index c8497453..ef89b4ca 100644 --- a/partner_programs/serializers/role_analytics.py +++ b/partner_programs/serializers/role_analytics.py @@ -47,3 +47,28 @@ class ProgramRoleWidgetSerializer(serializers.Serializer): participant = ParticipantWidgetSerializer(required=False) organizer = OrganizerWidgetSerializer(required=False) expert = ExpertWidgetSerializer(required=False) + + +class WidgetSolutionRefSerializer(serializers.Serializer): + kind = serializers.ChoiceField(choices=("solution",)) + program_id = serializers.IntegerField(min_value=1) + solution_id = serializers.IntegerField(min_value=1) + name = serializers.CharField() + + +class SolutionParticipantWidgetSerializer(ParticipantWidgetSerializer): + participant_project = None + application = WidgetSolutionRefSerializer(allow_null=True) + + +class SolutionOrganizerWidgetSerializer(serializers.Serializer): + participants = serializers.IntegerField(min_value=0) + solutions = serializers.IntegerField(min_value=0) + submitted_solutions = serializers.IntegerField(min_value=0, allow_null=True) + participants_without_solution = serializers.IntegerField(min_value=0) + + +class SolutionProgramRoleWidgetSerializer(ProgramRoleWidgetSerializer): + storage_mode = serializers.ChoiceField(choices=("solution",)) + participant = SolutionParticipantWidgetSerializer(required=False) + organizer = SolutionOrganizerWidgetSerializer(required=False) diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index 323c2fc3..66358357 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -21,7 +21,10 @@ get_filtered_program_project_links, validate_program_project_filters, ) -from partner_programs.services.publishing import publish_finished_program_projects +from partner_programs.services.publishing import ( + publish_finished_program_projects, + publish_finished_program_solutions, +) from partner_programs.services.registration import ( ProgramRegistrationError, create_user_and_register_to_program, @@ -46,6 +49,7 @@ "get_filtered_program_project_links", "prepare_project_scores_export_data", "publish_finished_program_projects", + "publish_finished_program_solutions", "register_user_to_program", "require_can_apply_project_to_program", "row_dict_for_link", diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 83213d23..ac298be4 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -1,6 +1,15 @@ from datetime import timedelta -from django.db.models import Case, CharField, Count, Exists, F, IntegerField, Min +from django.db.models import ( + BooleanField, + Case, + CharField, + Count, + Exists, + F, + IntegerField, + Min, +) from django.db.models import OuterRef, Q, Subquery, Value, When from django.db.models.functions import Coalesce, TruncDate, Trim from django.utils import timezone @@ -24,6 +33,8 @@ def _participant_profiles(program_id): Руководитель считается участником команды и без записи Collaborator. Поле project регистрационной анкеты не заменяет фактические связи команды. """ + from partner_programs.solution_participants import membership_expressions + leader_exists = Exists( PartnerProgramProject.objects.filter( partner_program_id=program_id, @@ -43,13 +54,26 @@ def _participant_profiles(program_id): project__program_links__partner_program_id=program_id, ) ) - return PartnerProgramUserProfile.objects.filter( - partner_program_id=program_id - ).annotate( + legacy = dict( is_project_leader=leader_exists, is_submitted_project_leader=submitted_leader_exists, is_project_collaborator=collaborator_exists, ) + solutions = membership_expressions(program_id) + return PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id + ).annotate( + **{ + key: Case( + When( + partner_program__solution_storage_mode="solution", then=solutions[key] + ), + default=value, + output_field=BooleanField(), + ) + for key, value in legacy.items() + } + ) def _without_team_filter(): diff --git a/partner_programs/services/attention_actions.py b/partner_programs/services/attention_actions.py index abf836ef..46e5f721 100644 --- a/partner_programs/services/attention_actions.py +++ b/partner_programs/services/attention_actions.py @@ -17,6 +17,7 @@ from rest_framework.exceptions import ValidationError from partner_programs.models import ( + PartnerProgram, ProgramAttentionReminder, ProgramAttentionReminderBatch, ) @@ -30,6 +31,12 @@ from partner_programs.services.assignment_analytics import annotated_assignment_queryset from partner_programs.services.exports import _literal_excel_cell from partner_programs.services.management import managed_program, person_name +from partner_programs.services.solution_attention import ( + SOLUTION_AWAITING, + solution_assignment_sources, + solution_recipient_targets, + solution_reminder_rows, +) from users.models import CustomUser logger = logging.getLogger(__name__) @@ -40,10 +47,13 @@ "participants-without-visit": "Участники еще не заходили на платформу", "participants-without-team": "Участники без команды", "projects-awaiting-evaluation": "Работы ожидают оценивания", + SOLUTION_AWAITING: "Решения ожидают оценивания", } def selected_rows(program, category, payload): + if category == SOLUTION_AWAITING: + return solution_reminder_rows(program, payload) search, selection = payload["search"], payload["selection"] if category == "projects-awaiting-evaluation": rows = projects_awaiting_evaluation_rows(program) @@ -78,11 +88,15 @@ def selected_rows(program, category, payload): def source_id(row, category): + if category == SOLUTION_AWAITING: + return row["application"]["solution_id"] return row.pk if category == "projects-awaiting-evaluation" else row["user_id"] def recipient_targets(program, category, rows): """One recipient per real user; no inferred or newly suggested assignments.""" + if category == SOLUTION_AWAITING: + return solution_recipient_targets(program, rows) if category != "projects-awaiting-evaluation": return [ { @@ -123,11 +137,19 @@ def message_for(program, category, recipient, rows): text = f"Вы зарегистрированы в программе «{program.name}». Зайдите на PROCOLLAB, чтобы начать участие." path = f"/office/program/{program.pk}" elif category == "participants-without-team": + work_type = "решение" if program.solution_storage_mode == "solution" else "проект" text = ( f"Вы участвуете в программе «{program.name}», но пока не состоите в команде. " - "Создайте проект или присоединитесь к команде." + f"Создайте {work_type} или присоединитесь к команде." ) path = f"/office/program/{program.pk}/members" + elif category == SOLUTION_AWAITING: + names = "; ".join(f'«{row["application"]["name"]}»' for row in rows) + text = ( + f"В программе «{program.name}» у вас остались незавершенные оценки по решениям: {names}. " + "Завершите оценивание на PROCOLLAB." + ) + path = f"/office/program/{program.pk}/solutions-rating" else: names = "; ".join(f"«{row.project.name}»" for row in rows) text = ( @@ -176,7 +198,7 @@ def preview_reminder(program, category, actor, payload): "selected_count": len(rows), "recipient_count": len(available), "without_assignments_count": len(rows) - len(assigned_sources) - if category == "projects-awaiting-evaluation" + if category in ("projects-awaiting-evaluation", SOLUTION_AWAITING) else 0, "unavailable_count": len(targets) - len(available), "preview_text": preview_text, @@ -196,7 +218,9 @@ def fresh_targets(program, category, ids, snapshot): ) current = {t["user_id"]: t for t in recipient_targets(program, category, rows)} assignment_sources = {} - if category == "projects-awaiting-evaluation": + if category == SOLUTION_AWAITING: + assignment_sources = solution_assignment_sources(rows) + elif category == "projects-awaiting-evaluation": links = {r.project_id: r.pk for r in rows} assignment_ids = [aid for t in current.values() for aid in t["assignment_ids"]] assignment_sources = { @@ -217,11 +241,12 @@ def fresh_targets(program, category, ids, snapshot): set(target["assignment_ids"]) & set(approved["assignment_ids"]) ) if target["source_ids"] and ( - category != "projects-awaiting-evaluation" or target["assignment_ids"] + category not in ("projects-awaiting-evaluation", SOLUTION_AWAITING) + or target["assignment_ids"] ): - # The project assignment may have been replaced after preview. Retain only - # sources belonging to the approved, still unfinished assignments. - if category == "projects-awaiting-evaluation": + # После предпросмотра назначение могли заменить. Оставляем только + # источники утвержденных и по-прежнему незавершенных назначений. + if category in ("projects-awaiting-evaluation", SOLUTION_AWAITING): target["source_ids"] = sorted( {assignment_sources[aid] for aid in target["assignment_ids"]} ) @@ -346,6 +371,8 @@ def deliver_reminder(pk): ): return program, category = notice.batch.program, notice.batch.category + if category == SOLUTION_AWAITING: + program = PartnerProgram.objects.select_for_update().get(pk=program.pk) rows, targets = fresh_targets( program, category, diff --git a/partner_programs/services/case_fields.py b/partner_programs/services/case_fields.py index 7946e930..e533ef06 100644 --- a/partner_programs/services/case_fields.py +++ b/partner_programs/services/case_fields.py @@ -51,10 +51,19 @@ def validate_program_case_configuration(field): errors["is_required"] = "Кейс обязателен перед сдачей проекта." if not field.show_filter: errors["show_filter"] = "Системный кейс должен быть доступен для фильтрации." - options = [value.strip() for value in (field.options or "").split("|")] - if not all(options): + options = ( + field.solution_options + if field.solution_options is not None + else [value.strip() for value in (field.options or "").split("|")] + ) + if ( + not isinstance(options, list) + or not options + or not all(isinstance(value, str) and value.strip() for value in options) + ): errors["options"] = "Укажите хотя бы один кейс без пустых вариантов." - elif len({value.casefold() for value in options}) != len(options): + options = [] + elif len({value.strip().casefold() for value in options}) != len(options): errors[ "options" ] = "Варианты кейсов не должны повторяться (без учета регистра и внешних пробелов)." diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 20b9faa6..ec6b5f45 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -70,6 +70,7 @@ def management_data(program): "updated_at": program.datetime_updated, "is_competitive": program.is_competitive, "is_distributed_evaluation": program.is_distributed_evaluation, + "solution_storage_mode": program.solution_storage_mode, "materials": [ material_data(m) for m in program.materials.select_related("file").order_by("pk") diff --git a/partner_programs/services/management_form.py b/partner_programs/services/management_form.py index b5ce8e18..b8026041 100644 --- a/partner_programs/services/management_form.py +++ b/partner_programs/services/management_form.py @@ -129,6 +129,13 @@ def form_data(program): @transaction.atomic def save_form(program_id, user, payload): program = managed_program(program_id, user, lock=True) + if ( + program.solution_storage_mode == "solution" + or program.fields.filter(solution_options__isnull=False).exists() + ): + raise ValidationError( + {"code": "solution_form_required", "detail": "Используйте форму решения."} + ) # Same link -> definition order as field PUT/submit; application creation # already locks the program, so the first project cannot race configuration. list( diff --git a/partner_programs/services/management_project_actions.py b/partner_programs/services/management_project_actions.py index e11a23a2..ad983f77 100644 --- a/partner_programs/services/management_project_actions.py +++ b/partner_programs/services/management_project_actions.py @@ -45,6 +45,10 @@ class ActionConflict(APIException): def load_links(program, ids): + if program.solution_storage_mode == "solution": + raise ActionConflict( + "Для заявки решения нельзя выполнять действия со связью проекта." + ) links = list( PartnerProgramProject.objects.filter(partner_program=program, pk__in=ids) .select_related("project") diff --git a/partner_programs/services/participant_removal.py b/partner_programs/services/participant_removal.py index 3fe93abb..b5faeadd 100644 --- a/partner_programs/services/participant_removal.py +++ b/partner_programs/services/participant_removal.py @@ -43,6 +43,14 @@ def selected_profiles(program, ids, *, lock=False): return profiles +def require_legacy_removal(program): + if program.solution_storage_mode == "solution": + raise RemovalConflict( + "solution_mode_unavailable", + "Нельзя удалять регистрации отдельно от заявки решения.", + ) + + def applications(program, profiles): return list( PartnerProgramProject.objects.filter( @@ -162,6 +170,7 @@ def normalized_snapshot(program, profiles, links, team_links): def preview_removal(program_id, actor, ids): program = managed_program(program_id, actor) + require_legacy_removal(program) profiles = selected_profiles(program, ids) links, team_links = application_effects(program, profiles) state = normalized_snapshot(program, profiles, links, team_links) @@ -224,6 +233,7 @@ def preview_removal(program_id, actor, ids): @transaction.atomic def remove_participants(program_id, actor, ids, token): program = managed_program(program_id, actor) + require_legacy_removal(program) try: proof = signing.loads(token, salt=SALT, max_age=900) except signing.BadSignature: @@ -261,6 +271,7 @@ def remove_participants(program_id, actor, ids, token): .order_by("pk") ) program = managed_program(program_id, actor) + require_legacy_removal(program) profiles = selected_profiles(program, ids, lock=True) links, team_links = application_effects(program, profiles) if affected_project_ids(links, team_links) != project_ids or normalized_snapshot( diff --git a/partner_programs/services/publishing.py b/partner_programs/services/publishing.py index f97fcfa9..1e071ec2 100644 --- a/partner_programs/services/publishing.py +++ b/partner_programs/services/publishing.py @@ -1,4 +1,5 @@ from django.utils import timezone +from django.db import transaction from partner_programs.models import ( PartnerProgram, @@ -6,6 +7,7 @@ PartnerProgramUserProfile, ) from projects.models import Project +from partner_programs.solution_models import ProgramSolution def publish_finished_program_projects(now=None) -> int: @@ -13,6 +15,7 @@ def publish_finished_program_projects(now=None) -> int: now = timezone.now() program_ids = PartnerProgram.objects.filter( + solution_storage_mode="legacy_project", publish_projects_after_finish=True, datetime_finished__lte=now, ).values_list("id", flat=True) @@ -33,3 +36,41 @@ def publish_finished_program_projects(now=None) -> int: is_public=False, draft=False, ).update(is_public=True) + + +def solution_publication_filter(now): + return dict( + solution_storage_mode="solution", + publish_projects_after_finish=True, + datetime_finished__lte=now, + ) + + +@transaction.atomic +def publish_solution_program(program_id, *, now): + program = ( + PartnerProgram.objects.select_for_update() + .filter(pk=program_id, **solution_publication_filter(now)) + .first() + ) + if program is None: + return 0 + solutions = ProgramSolution.objects.filter(program=program, published_at__isnull=True) + if program.is_competitive: + # Независимый согласованный путь сданных работ. Вопрос о публикации + # несданных конкурсных черновиков остается открытым. + solutions = solutions.filter(status="submitted") + return solutions.update(published_at=now) + + +def publish_finished_program_solutions(now=None) -> int: + now = timezone.now() if now is None else now + program_ids = ( + PartnerProgram.objects.filter(**solution_publication_filter(now)) + .order_by("pk") + .values_list("pk", flat=True) + ) + return sum( + publish_solution_program(program_id, now=now) + for program_id in program_ids.iterator() + ) diff --git a/partner_programs/services/results.py b/partner_programs/services/results.py index 07992c75..b1c06db2 100644 --- a/partner_programs/services/results.py +++ b/partner_programs/services/results.py @@ -1,7 +1,6 @@ """Organizer results: one calculation for the ranking and its Excel export.""" from collections import defaultdict -from decimal import Decimal, ROUND_HALF_UP from django.utils import timezone from rest_framework import serializers @@ -10,7 +9,7 @@ from partner_programs.services.case_fields import get_program_case_field from partner_programs.services.management import person_name from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -from project_rates.validators import ProjectScoreValidator +from project_rates.evaluation_math import evaluation_totals, assign_places, numeric class ResultsQuery(serializers.Serializer): @@ -28,27 +27,6 @@ class ResultsQuery(serializers.Serializer): ) -def rounded(value): - return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - - -def numeric(value): - return float(value) if value is not None else None - - -def valid_score(criterion, value): - try: - ProjectScoreValidator.validate( - criteria_type=criterion.type, - value=value, - criteria_min_value=criterion.min_value, - criteria_max_value=criterion.max_value, - ) - return True - except (TypeError, ValueError): - return False - - def results_data(program, query, *, project_link_id=None): criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) numeric_criteria = [c for c in criteria if c.type in ("int", "float")] @@ -75,32 +53,28 @@ def results_data(program, query, *, project_link_id=None): ).select_related("expert__user"): people[assignment.project_id][assignment.expert.user_id] = assignment.expert.user required = program.required_evaluators or program.max_project_rates or 1 - maximum = ( - sum(Decimal(str(c.max_value)) for c in numeric_criteria) - if numeric_criteria and all(c.max_value is not None for c in numeric_criteria) - else None - ) + maximum = evaluation_totals(criteria, {})["maximum"] buckets = {name: [] for name in [*options, None]} for link in links: + totals = evaluation_totals( + criteria, + { + user_id: values[(link.project_id, user_id)] + for user_id in people[link.project_id] + }, + ) experts = [] for user_id, user in sorted(people[link.project_id].items()): scores = values[(link.project_id, user_id)] - complete = bool(criteria) and all( - c.pk in scores and valid_score(c, scores[c.pk]) for c in criteria - ) - total = ( - sum(Decimal(scores[c.pk]) for c in numeric_criteria) - if complete and numeric_criteria - else None - ) + evaluation = totals["evaluations"][user_id] experts.append( { "user_id": user_id, "name": person_name(user), "email": user.email, "avatar": user.avatar or None, - "complete": complete, - "total": numeric(total), + "complete": evaluation["complete"], + "total": evaluation["total"], "values": [ {"criterion_id": c.pk, "value": scores.get(c.pk)} for c in criteria @@ -108,24 +82,8 @@ def results_data(program, query, *, project_link_id=None): } ) completed = [e for e in experts if e["complete"]] - score = ( - rounded(sum(Decimal(str(e["total"])) for e in completed) / len(completed)) - if completed and numeric_criteria - else None - ) - averages = { - str(c.pk): numeric( - rounded( - sum( - Decimal(values[(link.project_id, e["user_id"])][c.pk]) - for e in completed - ) - / len(completed) - ) - ) - for c in numeric_criteria - if completed - } + score = totals["score"] + averages = totals["averages"] buckets.setdefault(link.case_name, []).append( { "id": link.pk, @@ -146,17 +104,9 @@ def results_data(program, query, *, project_link_id=None): ) groups = [] for case_name, projects in buckets.items(): - ready = sorted( - [p for p in projects if p["ready"]], - key=lambda p: (-p["score"], p["name"].lower(), p["id"]), - ) - last_score, place = None, 0 # A single-project projection cannot determine a place among its peers. - for index, project in enumerate(ready if project_link_id is None else [], 1): - if project["score"] != last_score: - place = index - project["place"] = place - last_score = project["score"] + if project_link_id is None: + assign_places(projects, "id") if query["scope"] != "all": if query["case_scope"] == "selected" and case_name != query.get("case_name"): continue diff --git a/partner_programs/services/results_excel.py b/partner_programs/services/results_excel.py index 9a34f370..bacd8c20 100644 --- a/partner_programs/services/results_excel.py +++ b/partner_programs/services/results_excel.py @@ -20,6 +20,18 @@ def text_cell(sheet, row, column, value): def export_results(data): + return _export_results( + data, label="Проект", group_key="projects", id_key="project_id" + ) + + +def export_solution_results(data): + return _export_results( + data, label="Решение", group_key="solutions", id_key="solution_id" + ) + + +def _export_results(data, *, label, group_key, id_key): workbook = Workbook() workbook.calculation = CalcProperties(fullCalcOnLoad=True) rating = workbook.active @@ -31,19 +43,19 @@ def export_results(data): rating.append( [ "Место", - "Проект", + label, "Кейс", "Получено оценок", "Требуется оценок", *[c["name"] + " (среднее)" for c in numeric], "Итог", "Статус", - "ID проекта", + "ID решения" if id_key == "solution_id" else "ID проекта", ] ) experts.append( [ - "Проект", + label, "Кейс", "Эксперт", "Email", @@ -51,13 +63,13 @@ def export_results(data): *[c["name"] for c in numeric], "Сумма", *[c["name"] for c in other], - "ID проекта", + "ID решения" if id_key == "solution_id" else "ID проекта", "ID эксперта", ] ) total_col = 6 + len(numeric) for group in data["groups"]: - for project in group["projects"]: + for project in group[group_key]: expert_rows = [] start_row = experts.max_row + 1 for expert in project["experts"]: @@ -97,7 +109,7 @@ def export_results(data): if criterion["type"] == "bool" and value in ("True", "False"): value = "Да" if value == "True" else "Нет" text_cell(experts, row, col, value) - experts.cell(row, total_col + len(other) + 1, project["project_id"]) + experts.cell(row, total_col + len(other) + 1, project[id_key]) experts.cell(row, total_col + len(other) + 2, expert["user_id"]) row = rating.max_row + 1 if project["place"] is not None: @@ -132,7 +144,7 @@ def export_results(data): else "Нет оценок" ) text_cell(rating, row, total_col + 1, status) - rating.cell(row, total_col + 2, project["project_id"]) + rating.cell(row, total_col + 2, project[id_key]) for sheet in (rating, experts): sheet.freeze_panes = "D2" sheet.auto_filter.ref = sheet.dimensions diff --git a/partner_programs/services/role_analytics.py b/partner_programs/services/role_analytics.py index f5f3ccdf..d37c43e7 100644 --- a/partner_programs/services/role_analytics.py +++ b/partner_programs/services/role_analytics.py @@ -132,6 +132,12 @@ def participant_widget_metrics(program, user): def build_program_role_widget(program, user): role = require_widget_role(program, user) + if program.solution_storage_mode == "solution": + from partner_programs.services.solution_role_analytics import ( + build_solution_role_widget, + ) + + return build_solution_role_widget(program, user, role) result = { "program_id": program.pk, "role": role, diff --git a/partner_programs/services/solution_analytics.py b/partner_programs/services/solution_analytics.py new file mode 100644 index 00000000..d0f1b7f1 --- /dev/null +++ b/partner_programs/services/solution_analytics.py @@ -0,0 +1,413 @@ +"""Scoped read models for solution analytics; no Project projection or writes.""" + +from collections import defaultdict +from datetime import timedelta + +from django.db.models import Count, Prefetch +from django.db.models.functions import TruncDate +from django.utils import timezone + +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.services.analytics import ( + ACTIVITY_DAYS, + _get_participant_metrics, + _get_participant_regions, +) +from partner_programs.services.assignment_analytics import build_delayed_experts +from partner_programs.services.solution_case_analytics import ( + build_solution_case_analytics, +) +from partner_programs.solution_api import iso +from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember +from partner_programs.solution_schema import empty_value, form_data +from project_rates.evaluation_math import complete_values +from project_rates.models import Criteria +from project_rates.solution_evaluation import snapshot_case, snapshot_name +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore + + +class SolutionAnalyticsContext: + def __init__(self, program, *, now=None, solution_ids=None): + self.program = program + self.now = now if now is not None else timezone.now() + self.form = form_data(program) + self.case_field = next( + (field for field in self.form["fields"] if field["purpose"] == "case"), None + ) + self.options = self.case_field["options"] if self.case_field else [] + query = ProgramSolution.objects.filter(program=program) + if solution_ids is not None: + query = query.filter(pk__in=solution_ids) + self.solutions = list( + query.select_related("captain") + .prefetch_related( + "answers", + Prefetch( + "members", + queryset=ProgramSolutionMember.objects.select_related("user"), + ), + ) + .order_by("created_at", "pk") + ) + self.by_id = {solution.pk: solution for solution in self.solutions} + self.criteria = list( + Criteria.objects.filter(partner_program=program).order_by("pk") + ) + self.score_records = list( + SolutionScore.objects.filter( + solution_id__in=self.by_id, criteria__partner_program=program + ) + .order_by("solution_id", "user_id", "criteria_id") + .values( + "pk", + "solution_id", + "user_id", + "criteria_id", + "value", + "user__first_name", + "user__last_name", + "user__patronymic", + ) + ) + self.values = defaultdict(lambda: defaultdict(dict)) + for score in self.score_records: + self.values[score["solution_id"]][score["user_id"]][ + score["criteria_id"] + ] = score["value"] + self.assignments = [ + self.assignment(row) + for row in SolutionExpertAssignment.objects.filter( + partner_program=program, solution_id__in=self.by_id + ) + .select_related("expert__user") + .order_by("pk") + ] + self.assignments_by_solution = defaultdict(list) + for row in self.assignments: + self.assignments_by_solution[row["application"]["solution_id"]].append(row) + + def application(self, solution): + return dict( + kind="solution", + program_id=self.program.pk, + solution_id=solution.pk, + name=snapshot_name(solution), + ) + + def case_name(self, solution): + value = snapshot_case( + solution, draft_field_id=self.case_field["id"] if self.case_field else None + ) + return value if value in self.options else None + + def assignment(self, row): + solution = self.by_id[row.solution_id] + submitted = solution.status == "submitted" + own = self.values[solution.pk][row.expert.user_id] + status = ( + "not_ready" + if not submitted + else "completed" + if complete_values(self.criteria, own) + else "in_progress" + if self.criteria and own + else "pending" + ) + waiting = ( + max(solution.submitted_at, row.datetime_created) + if status in ("pending", "in_progress") and solution.submitted_at is not None + else None + ) + user = row.expert.user + return dict( + assignment_id=row.pk, + expert=dict( + expert_id=row.expert_id, + user_id=user.pk, + first_name=user.first_name, + last_name=user.last_name, + full_name=" ".join(filter(None, (user.first_name, user.last_name))), + avatar=user.avatar or None, + ), + application=self.application(solution), + status=status, + assigned_at=row.datetime_created, + submitted=submitted, + submitted_at=solution.submitted_at if submitted else None, + waiting_since=waiting, + waiting_seconds=max(0, int((self.now - waiting).total_seconds())) + if waiting + else None, + ) + + def assignment_scores(self, assignment): + values = self.values[assignment["application"]["solution_id"]][ + assignment["expert"]["user_id"] + ] + return [ + dict( + criterion_id=criterion.pk, + name=criterion.name, + description=criterion.description, + type=criterion.type, + min_value=criterion.min_value, + max_value=criterion.max_value, + value=values.get(criterion.pk), + is_scored=criterion.pk in values, + ) + for criterion in self.criteria + ] + + def state(self, solution): + if not self.program.is_competitive: + return "not_applicable" + if solution.status != "submitted": + return "not_submitted" + if not self.program.is_distributed_evaluation: + return "evaluated" if self.values[solution.pk] else "awaiting_evaluation" + rows = self.assignments_by_solution[solution.pk] + completed = sum(row["status"] == "completed" for row in rows) + if not rows or not completed: + return "awaiting_evaluation" + required = self.program.required_evaluators or len(rows) + return "partially_evaluated" if completed < required else "evaluated" + + def applied_form_and_answers(self, solution): + if solution.status == "submitted": + snapshot = solution.submission_snapshot or {} + return snapshot.get("form", {}).get("fields", []), { + row["field_id"]: row["value"] for row in snapshot.get("answers", []) + } + return self.form["fields"], { + row.field_id: row.value for row in solution.answers.all() + } + + def coverage(self): + definitions = {field["id"]: field for field in self.form["fields"]} + counts = defaultdict( + lambda: dict( + applicable=0, answered=0, required_applicable=0, required_unanswered=0 + ) + ) + for solution in self.solutions: + fields, answers = self.applied_form_and_answers(solution) + for field in fields: + key = field["id"] + definitions.setdefault(key, field) + row = counts[key] + present = not empty_value(answers.get(key)) + row["applicable"] += 1 + row["answered"] += int(present) + row["required_applicable"] += int(field["required"]) + row["required_unanswered"] += int(field["required"] and not present) + return [ + dict( + field_id=field["id"], + label=field["label"], + type=field["type"], + purpose=field["purpose"], + **counts[key] + ) + for key, field in definitions.items() + ] + + def team(self, solution): + if solution.status == "submitted": + return (solution.submission_snapshot or {}).get("members", []) + captain = solution.captain + return [ + dict( + user=dict( + id=captain.pk, + first_name=captain.first_name, + last_name=captain.last_name, + avatar_url=captain.avatar or None, + ), + role="Капитан", + is_captain=True, + ), + *[ + dict( + user=dict( + id=m.user_id, + first_name=m.user.first_name, + last_name=m.user.last_name, + avatar_url=m.user.avatar or None, + ), + role=m.role, + is_captain=False, + ) + for m in solution.members.all() + ], + ] + + def solution_row(self, solution): + captain = next(row["user"] for row in self.team(solution) if row["is_captain"]) + state = self.state(solution) + applicable = ( + self.program.is_competitive and self.program.is_distributed_evaluation + ) + assignments = self.assignments_by_solution[solution.pk] + total = len(assignments) if applicable else None + completed = ( + sum(row["status"] == "completed" for row in assignments) + if applicable + else None + ) + reason = None + if state in ("awaiting_evaluation", "partially_evaluated"): + reason = ( + "awaiting_first_evaluation" + if total is None + else "no_assignments" + if total == 0 + else "no_completed_evaluations" + if completed == 0 + else "partially_evaluated" + ) + return dict( + application=self.application(solution), + captain=dict( + user_id=captain["id"], + full_name=" ".join( + filter(None, (captain["first_name"], captain["last_name"])) + ), + avatar=captain.get("avatar_url"), + ), + case_name=self.case_name(solution), + submission_status=solution.status, + evaluation_status=state, + created_at=iso(solution.created_at), + submitted_at=iso(solution.submitted_at), + assignments_total=total, + assignments_completed=completed, + waiting_reason=reason, + ) + + def overview(self): + program = self.program + participants = _get_participant_metrics(program.pk) + regions = _get_participant_regions(program.pk) + competitive = program.is_competitive + states = [self.state(solution) for solution in self.solutions] + submitted = sum(solution.status == "submitted" for solution in self.solutions) + completed = sum(row["status"] == "completed" for row in self.assignments) + return dict( + program_id=program.pk, + summary=dict( + participants=dict(total=participants["unique_participants"]), + solutions=dict(total=len(self.solutions)), + experts=dict(total=program.experts.count()), + participant_regions=dict(total=len(regions), items=regions), + ), + participant_funnel=dict( + **{ + key: participants[key] + for key in ( + "registrations", + "unique_participants", + "visited_participants", + "onboarded_participants", + "with_team", + ) + }, + captains=participants["project_creators"], + submitted_captains=participants["submitted_project_creators"] + if competitive + else None + ), + solution_funnel=dict( + created=len(self.solutions), + submission_applicable=competitive, + not_submitted=len(self.solutions) - submitted if competitive else None, + submitted=submitted if competitive else None, + evaluated=states.count("evaluated") if competitive else None, + ), + evaluation_status=dict( + applicable=competitive, + mode="distributed" if program.is_distributed_evaluation else "open", + required_evaluators=program.required_evaluators, + max_evaluations_per_solution=program.max_project_rates, + assignments=dict( + total=len(self.assignments), + pending=len(self.assignments) - completed, + evaluated=completed, + ) + if competitive and program.is_distributed_evaluation + else None, + solutions=dict( + submitted=submitted, + awaiting_evaluation=states.count("awaiting_evaluation"), + partially_evaluated=states.count("partially_evaluated"), + evaluated=states.count("evaluated"), + ) + if competitive + else None, + ), + attention=dict( + participants_without_visit=participants["without_visit"], + participants_without_onboarding=participants["without_onboarding"], + participants_without_team=participants["without_team"], + solutions_awaiting_evaluation=sum( + state in ("awaiting_evaluation", "partially_evaluated") + for state in states + ) + if competitive + else None, + solutions_not_submitted=len(self.solutions) - submitted + if competitive + else None, + delayed_experts=build_delayed_experts( + self.assignments, + evaluation_ends=program.datetime_evaluation_ends, + now=self.now, + ) + if competitive and program.is_distributed_evaluation + else dict(total=0, items=[]), + ), + activity=solution_activity(program), + cases=build_solution_case_analytics(program), + field_coverage=self.coverage(), + ) + + +def solution_activity(program): + today = timezone.localdate() + start = today - timedelta(days=ACTIVITY_DAYS - 1) + registrations = dict( + PartnerProgramUserProfile.objects.filter( + partner_program=program, + datetime_created__date__gte=start, + datetime_created__date__lte=today, + ) + .annotate(day=TruncDate("datetime_created")) + .values("day") + .annotate(total=Count("pk")) + .values_list("day", "total") + ) + submissions = ( + dict( + ProgramSolution.objects.filter( + program=program, + status="submitted", + submitted_at__date__gte=start, + submitted_at__date__lte=today, + ) + .annotate(day=TruncDate("submitted_at")) + .values("day") + .annotate(total=Count("pk")) + .values_list("day", "total") + ) + if program.is_competitive + else {} + ) + return [ + dict( + date=day.isoformat(), + registrations=registrations.get(day, 0), + submitted_solutions=submissions.get(day, 0) + if program.is_competitive + else None, + ) + for day in (start + timedelta(days=offset) for offset in range(ACTIVITY_DAYS)) + ] diff --git a/partner_programs/services/solution_archive_actions.py b/partner_programs/services/solution_archive_actions.py new file mode 100644 index 00000000..b483ba47 --- /dev/null +++ b/partner_programs/services/solution_archive_actions.py @@ -0,0 +1,290 @@ +"""Signed withdrawal retains work independently of active program participation.""" + +import copy +import hashlib +import json +import logging + +from django.core import signing +from django.db import models, transaction +from django.http import Http404 +from django.utils import timezone + +from files.models import UserFile +from partner_programs.permissions import can_manage_program +from partner_programs.solution_api import iso, solution_data +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionAdministrativeChange, + SolutionInvitation, +) +from partner_programs.solution_team import program_for +from project_rates.solution_models import SolutionExpertAssignment + +SALT = "solution-archive-actions-v1" +MAX_AGE = 600 +logger = logging.getLogger(__name__) + + +def conflict(): + return SolutionError( + "solution_action_conflict", + "Данные решений изменились. Проверьте их и подтвердите действие заново.", + ) + + +def context(program_id, actor, ids): + program = program_for(program_id, lock=True) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", "Действие доступно организатору программы.", status=403 + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + if ( + not isinstance(ids, list) + or not 1 <= len(ids) <= 10000 + or any( + isinstance(pk, bool) or not isinstance(pk, int) or not 0 < pk <= MAX_ID + for pk in ids + ) + or len(ids) != len(set(ids)) + ): + raise SolutionError( + "validation_error", "Выберите корректные решения без повторов.", status=400 + ) + rows = list( + ProgramSolution.all_objects.select_for_update() + .filter(program=program, pk__in=ids) + .order_by("pk") + ) + if len(rows) != len(ids): + raise Http404 + list(program.fields.select_for_update().order_by("pk")) + list( + ProgramSolutionMember.objects.select_for_update() + .filter(solution__in=rows) + .order_by("pk") + ) + list( + SolutionInvitation.objects.select_for_update() + .filter(solution__in=rows) + .order_by("pk") + ) + urls = { + a.value["url"] + for row in rows + for a in row.answers.all() + if isinstance(a.value, dict) and isinstance(a.value.get("url"), str) + } + files = list( + UserFile.objects.select_for_update().filter(pk__in=urls).order_by("pk").values() + ) + for row in rows: + row.program = program + return program, rows, files + + +def inspect(program, rows, actor, files): + items, state = [], [] + for solution in rows: + errors = ( + [issue("ids", "solution_archived", "Решение уже снято с участия.")] + if solution.archived_at + else [] + ) + data = solution_data(solution, actor) + assignments = list(solution.expert_assignments.order_by("pk").values()) + scores = list(solution.scores.order_by("pk").values()) + invitations = list(solution.invitations.order_by("pk").values()) + items.append( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=data["name"], + ), + solution_version=solution.version, + is_submitted=solution.status == "submitted", + is_archived=solution.archived_at is not None, + score_count=len(scores), + removed_assignments_count=len(assignments), + revoked_invitation_count=sum( + i["status"] == "pending" for i in invitations + ), + errors=errors, + ) + ) + state.append( + dict( + solution=list( + ProgramSolution.all_objects.filter(pk=solution.pk).values() + ), + material=data, + answers=list(solution.answers.order_by("pk").values()), + members=list(solution.members.order_by("pk").values()), + assignments=assignments, + scores=scores, + invitations=invitations, + ) + ) + digest = hashlib.sha256( + json.dumps( + dict( + program=[ + program.pk, + program.solution_storage_mode, + program.is_competitive, + ], + rows=state, + files=files, + ), + sort_keys=True, + default=str, + ).encode() + ).hexdigest() + return ( + dict( + items=items, + score_count=sum(i["score_count"] for i in items), + removed_assignments_count=sum(i["removed_assignments_count"] for i in items), + revoked_invitation_count=sum(i["revoked_invitation_count"] for i in items), + can_apply=not any(i["errors"] for i in items), + ), + digest, + ) + + +def proof_context(program, actor, ids): + return dict(program=program.pk, actor=actor.pk, ids=sorted(ids)) + + +@transaction.atomic +def preview(program_id, actor, data): + program, rows, files = context(program_id, actor, data["ids"]) + result, state = inspect(program, rows, actor, files) + result["preview_token"] = ( + signing.dumps( + {**proof_context(program, actor, data["ids"]), "state": state}, + salt=SALT, + compress=True, + ) + if result["can_apply"] + else None + ) + return result + + +def archive_locked(solution, actor, now, *, reason="detached", registration_removal=None): + """Internal writer after authorization/proof recheck and all context locks.""" + if solution.archived_at is not None or reason not in ( + "detached", + "registration_removed", + ): + raise conflict() + if (reason == "registration_removed") != (registration_removal is not None): + raise conflict() + before = copy.deepcopy(solution.submission_snapshot) + version = solution.version + saved = solution_data(solution, actor) + form = copy.deepcopy(saved["form"]) + form["is_snapshot"] = True + captured = dict( + name=saved["name"], + captain_id=saved["captain_id"], + members=copy.deepcopy(saved["team"]["members"]), + form=form, + answers=copy.deepcopy(saved["answers"]), + captured_at=iso(now), + ) + pending = list( + solution.invitations.filter(status="pending") + .order_by("pk") + .values_list("pk", flat=True) + ) + removed = list( + solution.expert_assignments.order_by("pk").values_list("pk", flat=True) + ) + score_count = solution.scores.count() + solution.invitations.filter(pk__in=pending, status="pending").delete() + # The approved detach exception removes even scored assignments, as the + # legacy program-link writer does. Scope is the confirmed locked rows; + # normal SolutionAssignmentQuerySet.delete keeps its score protection. + models.QuerySet.delete( + SolutionExpertAssignment.objects.filter(solution=solution, pk__in=removed) + ) + changed = ProgramSolution.all_objects.filter( + pk=solution.pk, archived_at__isnull=True + ).update( + archived_at=now, + archived_by=actor, + archive_reason=reason, + archive_snapshot=captured, + version=version + 1, + updated_at=now, + ) + if changed != 1: + raise conflict() + change = SolutionAdministrativeChange.objects.create( + solution=solution, + actor=actor, + actor_name=f"{actor.first_name} {actor.last_name}".strip(), + action="archive", + registration_removal=registration_removal, + before_version=version, + after_version=version + 1, + removed_assignment_ids=removed, + revoked_invitation_ids=pending, + score_count=score_count, + was_submitted=solution.status == "submitted", + before_snapshot=before, + after_snapshot=copy.deepcopy(before), + archived_at=now, + archive_reason=reason, + archive_snapshot=copy.deepcopy(captured), + ) + return ( + dict( + application=dict( + kind="solution", + program_id=solution.program_id, + solution_id=solution.pk, + name=captured["name"], + ), + solution_version=version + 1, + archived_at=iso(now), + ), + change, + ) + + +@transaction.atomic +def apply(program_id, actor, data): + program, rows, files = context(program_id, actor, data["ids"]) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=MAX_AGE) + except signing.BadSignature as error: + raise conflict() from error + expected = proof_context(program, actor, data["ids"]) + if not isinstance(proof, dict) or any(proof.get(k) != v for k, v in expected.items()): + raise conflict() + result, state = inspect(program, rows, actor, files) + if not result["can_apply"] or proof.get("state") != state: + raise conflict() + now = timezone.now() + items = [] + for solution in rows: + item, _ = archive_locked(solution, actor, now) + items.append(item) + logger.info( + "Program solution archive program=%s actor=%s solutions=%s", + program.pk, + actor.pk, + data["ids"], + ) + return dict(changed_count=len(items), items=items) diff --git a/partner_programs/services/solution_attention.py b/partner_programs/services/solution_attention.py new file mode 100644 index 00000000..95fb1bad --- /dev/null +++ b/partner_programs/services/solution_attention.py @@ -0,0 +1,94 @@ +"""Выбор решений и настоящих незавершенных назначений для напоминания.""" + +from django.db.models import CharField +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Coalesce +from rest_framework.exceptions import ValidationError + +from partner_programs.serializers.attention_actions import MAX_SELECTION +from partner_programs.services.solution_analytics import SolutionAnalyticsContext +from partner_programs.solution_models import ProgramSolution +from project_rates.solution_models import SolutionExpertAssignment + +SOLUTION_AWAITING = "solutions-awaiting-evaluation" + + +def solution_reminder_rows(program, payload): + if not ( + program.solution_storage_mode == "solution" + and program.is_competitive + and program.is_distributed_evaluation + ): + return [] + query = ProgramSolution.objects.filter(program=program, status="submitted") + if payload["search"]: + query = query.annotate( + attention_name=Coalesce( + KeyTextTransform("name", "submission_snapshot"), + "name", + output_field=CharField(), + ) + ).filter(attention_name__icontains=payload["search"]) + selection = payload["selection"] + if selection["mode"] == "selected": + query = query.filter(pk__in=selection["ids"]) + else: + query = query.exclude(pk__in=selection["excluded_ids"]) + ids = list(query.order_by("submitted_at", "pk").values_list("pk", flat=True)) + if not ids: + return [] + context = SolutionAnalyticsContext(program, solution_ids=ids) + rows = { + solution.pk: dict( + application=context.application(solution), + pending_assignments=[ + row + for row in context.assignments_by_solution[solution.pk] + if row["status"] in ("pending", "in_progress") + ], + ) + for solution in context.solutions + if context.state(solution) in ("awaiting_evaluation", "partially_evaluated") + } + if len(rows) > MAX_SELECTION: + raise ValidationError( + { + "detail": f"За один раз можно выбрать до {MAX_SELECTION} строк. Уточните поиск." + } + ) + return [rows[solution_id] for solution_id in ids if solution_id in rows] + + +def solution_recipient_targets(program, rows): + assignment_sources = solution_assignment_sources(rows) + assignments = ( + SolutionExpertAssignment.objects.filter( + pk__in=assignment_sources, + partner_program=program, + solution__program=program, + solution__status="submitted", + solution__archived_at__isnull=True, + expert__programs=program, + expert__user__is_active=True, + ) + .order_by("pk") + .values("pk", "solution_id", "expert__user_id") + ) + targets = {} + for assignment in assignments: + uid = assignment["expert__user_id"] + target = targets.setdefault( + uid, {"user_id": uid, "source_ids": [], "assignment_ids": []} + ) + if assignment["solution_id"] not in target["source_ids"]: + target["source_ids"].append(assignment["solution_id"]) + target["assignment_ids"].append(assignment["pk"]) + return list(targets.values()) + + +def solution_assignment_sources(rows): + return { + assignment["assignment_id"]: row["application"]["solution_id"] + for row in rows + for assignment in row["pending_assignments"] + } diff --git a/partner_programs/services/solution_case_actions.py b/partner_programs/services/solution_case_actions.py new file mode 100644 index 00000000..f5bb19d9 --- /dev/null +++ b/partner_programs/services/solution_case_actions.py @@ -0,0 +1,331 @@ +"""Confirmed organizer case changes retain the original submission in history.""" + +import copy +import hashlib +import json +import logging + +from django.core import signing +from django.db import transaction +from django.http import Http404 +from django.utils import timezone + +from partner_programs.permissions import can_manage_program +from partner_programs.solution_api import mutation_receipt +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionAdministrativeChange, +) +from partner_programs.solution_schema import form_data +from partner_programs.solution_team import ( + advance_version, + program_for, +) +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore + +SALT = "solution-case-actions-v1" +MAX_AGE = 600 +logger = logging.getLogger(__name__) + + +def conflict(errors=None): + return SolutionError( + "solution_action_conflict", + "Данные решения изменились. Проверьте их и подтвердите действие заново.", + errors=errors or [], + ) + + +def context(program_id, solution_id, actor): + program = program_for(program_id, lock=True) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", + "Действие доступно организатору программы.", + status=403, + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + if not 0 < solution_id <= MAX_ID: + raise Http404 + try: + solution = ProgramSolution.objects.select_for_update().get( + program=program, pk=solution_id + ) + except ProgramSolution.DoesNotExist: + raise Http404 + solution.program = program + list(program.fields.select_for_update().order_by("pk")) + list(solution.answers.select_for_update().order_by("pk")) + list(solution.members.select_for_update().order_by("pk")) + form = form_data(program) + field = next((f for f in form["fields"] if f["purpose"] == "case"), None) + if not form["is_configured"] or field is None: + raise SolutionError("form_not_configured", "Организатор еще не настроил форму.") + return program, solution, form, field + + +def applied_field(solution, field): + if solution.status == "submitted": + saved = next( + ( + item + for item in solution.submission_snapshot["form"]["fields"] + if item["purpose"] == "case" + ), + None, + ) + if saved and solution.program.fields.filter(pk=saved["id"]).exists(): + return saved + return field + + +def inspect(program, solution, form, field, case_name): + if ( + not isinstance(case_name, str) + or not case_name.strip() + or case_name not in field["options"] + ): + raise SolutionError( + "invalid_answers", + "Выберите актуальный кейс.", + status=400, + errors=[ + issue( + "case_name", + "case_unavailable", + "Выбранный кейс больше недоступен. Выберите актуальный кейс.", + field["id"], + ) + ], + ) + target = applied_field(solution, field) + answers = list(solution.answers.order_by("pk").values("id", "field_id", "value")) + applied_answers = ( + solution.submission_snapshot["answers"] + if solution.status == "submitted" + else answers + ) + current = next( + (a["value"] for a in applied_answers if a["field_id"] == target["id"]), None + ) + assignments = list( + SolutionExpertAssignment.objects.filter(solution=solution) + .order_by("pk") + .values("id", "expert_id", "expert__user_id", "partner_program_id") + ) + scores = list( + SolutionScore.objects.filter(solution=solution) + .order_by("pk") + .values("id", "criteria_id", "user_id", "value") + ) + scopes = list( + program.expert_case_scopes.order_by("pk").values( + "id", "expert_id", "all_cases", "case_names" + ) + ) + protected = {score["user_id"] for score in scores} + scoped = {scope["expert_id"]: scope for scope in scopes} + removed = [ + row["id"] + for row in assignments + if row["expert__user_id"] not in protected + and row["expert_id"] in scoped + and not scoped[row["expert_id"]]["all_cases"] + and case_name not in scoped[row["expert_id"]]["case_names"] + ] + errors = [] + if solution.status != "submitted" and (assignments or scores): + errors.append( + issue( + "case_name", + "evaluation_started", + "У решения уже есть данные оценивания.", + field["id"], + ) + ) + elif current == case_name: + errors.append( + issue("case_name", "case_unchanged", "Выберите другой кейс.", field["id"]) + ) + state = dict( + program=[program.pk, program.solution_storage_mode, program.is_competitive], + form=form, + solution=list( + ProgramSolution.objects.filter(pk=solution.pk).values( + "id", + "captain_id", + "name", + "version", + "status", + "submitted_at", + "submission_snapshot", + "published_at", + ) + ), + answers=answers, + members=list( + ProgramSolutionMember.objects.filter(solution=solution) + .order_by("pk") + .values("id", "user_id", "role", "specialization") + ), + assignments=assignments, + scores=scores, + scopes=scopes, + ) + digest = hashlib.sha256( + json.dumps(state, sort_keys=True, default=str).encode() + ).hexdigest() + return ( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=solution.name, + ), + case_field_id=target["id"], + current_case=current if isinstance(current, str) else None, + case_name=case_name, + solution_version=solution.version, + errors=errors, + can_apply=not errors, + is_submitted=solution.status == "submitted", + removed_assignments_count=len(removed), + score_count=len(scores), + ), + digest, + removed, + ) + + +def proof_context(program, solution, actor, case_name): + return dict(actor=actor.pk, program=program.pk, solution=solution.pk, case=case_name) + + +@transaction.atomic +def preview(program_id, solution_id, actor, data): + program, solution, form, field = context(program_id, solution_id, actor) + result, state, _ = inspect(program, solution, form, field, data["case_name"]) + result["preview_token"] = ( + signing.dumps( + { + **proof_context(program, solution, actor, data["case_name"]), + "state": state, + }, + salt=SALT, + compress=True, + ) + if result["can_apply"] + else None + ) + return result + + +@transaction.atomic +def apply(program_id, solution_id, actor, data): + program, solution, form, field = context(program_id, solution_id, actor) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=MAX_AGE) + except signing.BadSignature as error: + raise conflict() from error + expected = proof_context(program, solution, actor, data["case_name"]) + if not isinstance(proof, dict) or any(proof.get(k) != v for k, v in expected.items()): + raise conflict() + result, state, removed = inspect(program, solution, form, field, data["case_name"]) + if proof.get("state") != state or not result["can_apply"]: + raise conflict(result["errors"]) + before_version = solution.version + before_snapshot = copy.deepcopy(solution.submission_snapshot) + if solution.status == "submitted": + target = applied_field(solution, field) + after = copy.deepcopy(before_snapshot) + if not any(item["id"] == target["id"] for item in after["form"]["fields"]): + old_case_ids = { + item["id"] + for item in after["form"]["fields"] + if item["purpose"] == "case" + } + if old_case_ids: + after["form"]["fields"] = [ + copy.deepcopy(target) if item["id"] in old_case_ids else item + for item in after["form"]["fields"] + ] + after["answers"] = [ + item + for item in after["answers"] + if item["field_id"] not in old_case_ids + ] + else: + after["form"]["fields"].append(copy.deepcopy(target)) + after["form"]["revision"] = hashlib.sha256( + json.dumps( + dict( + template=after["form"]["template"], fields=after["form"]["fields"] + ), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + answer = next( + (item for item in after["answers"] if item["field_id"] == target["id"]), None + ) + if answer is None: + after["answers"].append(dict(field_id=target["id"], value=data["case_name"])) + else: + answer["value"] = data["case_name"] + # Only this confirmed manager writer bypasses ordinary frozen model writes. + saved = solution.answers.filter(field_id=target["id"]) + if saved.exists(): + saved.update(value=data["case_name"]) + else: + ProgramSolutionAnswer.objects.bulk_create( + [ + ProgramSolutionAnswer( + solution=solution, field_id=target["id"], value=data["case_name"] + ) + ] + ) + solution.submission_snapshot = after + solution.version += 1 + solution.updated_at = timezone.now() + ProgramSolution.objects.filter(pk=solution.pk).update( + submission_snapshot=after, + version=solution.version, + updated_at=solution.updated_at, + ) + else: + ProgramSolutionAnswer.objects.update_or_create( + solution=solution, field_id=field["id"], defaults={"value": data["case_name"]} + ) + advance_version(solution) + SolutionExpertAssignment.objects.filter(pk__in=removed).delete() + SolutionAdministrativeChange.objects.create( + solution=solution, + actor=actor, + actor_name=f"{actor.first_name} {actor.last_name}".strip(), + action="change_case", + before_version=before_version, + after_version=solution.version, + case_field_id=result["case_field_id"], + previous_case=result["current_case"], + case_name=data["case_name"], + removed_assignment_ids=removed, + score_count=result["score_count"], + was_submitted=solution.status == "submitted", + before_snapshot=before_snapshot, + after_snapshot=copy.deepcopy(solution.submission_snapshot), + ) + logger.info( + "Program solution case change program=%s actor=%s solution=%s", + program.pk, + actor.pk, + solution.pk, + ) + return mutation_receipt(solution) diff --git a/partner_programs/services/solution_case_analytics.py b/partner_programs/services/solution_case_analytics.py new file mode 100644 index 00000000..535d6b22 --- /dev/null +++ b/partner_programs/services/solution_case_analytics.py @@ -0,0 +1,49 @@ +"""Case aggregates of owned solutions; legacy project links are never a source.""" + +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.solution_models import ProgramSolution +from project_rates.solution_evaluation import snapshot_case, team_user_ids + + +def build_solution_case_analytics(program): + field = get_program_case_field(program) + options = field.get_options_list() if field else [] + + def empty(): + return dict( + participants_total=0, + solutions_total=0, + not_submitted=0 if program.is_competitive else None, + submitted=0 if program.is_competitive else None, + ) + + buckets = {name: empty() for name in options} + without_case = empty() + participants = {name: set() for name in [*options, None]} + registered = set( + PartnerProgramUserProfile.objects.filter( + partner_program=program, user_id__isnull=False + ).values_list("user_id", flat=True) + ) + rows = ProgramSolution.objects.filter(program=program).prefetch_related( + "answers", "members" + ) + for solution in rows: + case = snapshot_case(solution, draft_field_id=field.pk if field else None) + key = case if case in buckets else None + metrics = buckets[key] if key is not None else without_case + metrics["solutions_total"] += 1 + if program.is_competitive: + state = "submitted" if solution.status == "submitted" else "not_submitted" + metrics[state] += 1 + participants[key].update(team_user_ids(solution) & registered) + for key, metrics in [*buckets.items(), (None, without_case)]: + metrics["participants_total"] = len(participants[key]) + return dict( + program_id=program.pk, + configured=field is not None, + submission_applicable=program.is_competitive, + items=[dict(name=name, **metrics) for name, metrics in buckets.items()], + without_case=without_case, + ) diff --git a/partner_programs/services/solution_member_actions.py b/partner_programs/services/solution_member_actions.py new file mode 100644 index 00000000..ac882674 --- /dev/null +++ b/partner_programs/services/solution_member_actions.py @@ -0,0 +1,269 @@ +"""Confirmed organizer removal preserves materials, registration and evaluations.""" + +import copy +import hashlib +import json +import logging +from contextvars import ContextVar + +from django.core import signing +from django.db import transaction +from django.http import Http404 +from django.utils import timezone + +from partner_programs.permissions import can_manage_program +from partner_programs.solution_api import iso, mutation_receipt, user_data +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_models import ProgramSolution, SolutionAdministrativeChange +from partner_programs.solution_team import program_for + +SALT = "solution-member-removal-v1" +MAX_AGE = 600 +logger = logging.getLogger(__name__) +_confirmed_deletion = ContextVar("solution_confirmed_member_deletion", default=None) + + +def permits_member_deletion(instance): + # Only one exact row inside the confirmed writer's transaction. Ordinary + # model/queryset/admin/cascade deletes never acquire this capability. + return _confirmed_deletion.get() == ( + instance.solution_id, + instance.pk, + instance.user_id, + ) + + +def delete_confirmed_member(member): + """Internal writer, after permission/proof recheck and program/solution locks.""" + token = _confirmed_deletion.set((member.solution_id, member.pk, member.user_id)) + try: + member.delete() + finally: + _confirmed_deletion.reset(token) + + +def conflict(errors=None): + return SolutionError( + "solution_action_conflict", + "Состав или данные решения изменились. Проверьте их и подтвердите действие заново.", + errors=errors or [], + ) + + +def context(program_id, solution_id, actor): + program = program_for(program_id, lock=True) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", "Действие доступно организатору программы.", status=403 + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + if not 0 < solution_id <= MAX_ID: + raise Http404 + try: + solution = ProgramSolution.objects.select_for_update().get( + program=program, pk=solution_id + ) + except ProgramSolution.DoesNotExist: + raise Http404 + solution.program = program + members = list( + solution.members.select_for_update(of=("self",)) + .select_related("user") + .order_by("pk") + ) + invitations = list(solution.invitations.select_for_update().order_by("pk")) + return program, solution, members, invitations + + +def inspect(program, solution, members, invitations, user_id): + if ( + isinstance(user_id, bool) + or not isinstance(user_id, int) + or not 0 < user_id <= MAX_ID + ): + raise SolutionError( + "validation_error", "Укажите корректного участника.", status=400 + ) + member = next((m for m in members if m.user_id == user_id), None) + if member is None: + raise Http404 + removed_member = dict( + user=user_data(member.user), + role=member.role, + specialization=member.specialization, + joined_at=iso(member.joined_at), + is_captain=member.user_id == solution.captain_id, + ) + errors = [] + if solution.status == "submitted": + saved = next( + ( + m + for m in solution.submission_snapshot["members"] + if m["user"]["id"] == user_id + ), + None, + ) + if saved is None: + errors.append( + issue("user_id", "team_state_changed", "Состав решения требует проверки.") + ) + else: + removed_member = copy.deepcopy(saved) + if member.user_id == solution.captain_id: + errors.append( + issue( + "user_id", + "captain_cannot_be_removed", + "Капитана нельзя исключить из состава. Для него снимите заявку с участия.", + ) + ) + pending = [ + row.pk + for row in invitations + if row.recipient_id == user_id and row.status == "pending" + ] + scores = list( + solution.scores.order_by("pk").values("id", "criteria_id", "user_id", "value") + ) + state = dict( + program=[program.pk, program.solution_storage_mode, program.is_competitive], + solution=list(ProgramSolution.objects.filter(pk=solution.pk).values()), + members=[ + dict( + id=m.pk, + user_id=m.user_id, + role=m.role, + specialization=m.specialization, + joined_at=m.joined_at, + user=user_data(m.user), + ) + for m in members + ], + invitations=[ + dict( + id=i.pk, + recipient_id=i.recipient_id, + status=i.status, + role=i.role, + specialization=i.specialization, + motivational_letter=i.motivational_letter, + version=i.version, + updated_at=i.updated_at, + ) + for i in invitations + ], + answers=list(solution.answers.order_by("pk").values()), + assignments=list(solution.expert_assignments.order_by("pk").values()), + scores=scores, + ) + digest = hashlib.sha256( + json.dumps(state, sort_keys=True, default=str).encode() + ).hexdigest() + return ( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=solution.name, + ), + member=removed_member, + solution_version=solution.version, + is_submitted=solution.status == "submitted", + remaining_member_count=len(members) - 1, + revoked_invitation_count=len(pending), + score_count=len(scores), + errors=errors, + can_apply=not errors, + ), + digest, + member, + pending, + ) + + +def proof_context(program, solution, actor, user_id): + return dict(actor=actor.pk, program=program.pk, solution=solution.pk, user=user_id) + + +@transaction.atomic +def preview(program_id, solution_id, actor, data): + program, solution, members, invitations = context(program_id, solution_id, actor) + result, state, _, _ = inspect( + program, solution, members, invitations, data["user_id"] + ) + result["preview_token"] = ( + signing.dumps( + {**proof_context(program, solution, actor, data["user_id"]), "state": state}, + salt=SALT, + compress=True, + ) + if result["can_apply"] + else None + ) + return result + + +@transaction.atomic +def apply(program_id, solution_id, actor, data): + program, solution, members, invitations = context(program_id, solution_id, actor) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=MAX_AGE) + except signing.BadSignature as error: + raise conflict() from error + expected = proof_context(program, solution, actor, data["user_id"]) + if not isinstance(proof, dict) or any(proof.get(k) != v for k, v in expected.items()): + raise conflict() + if not any(m.user_id == data["user_id"] for m in members): + # Replaying a consumed confirmation is a conflict; a fresh preview of + # an absent member is 404. Do not make a second revision. + raise conflict() + result, state, member, pending = inspect( + program, solution, members, invitations, data["user_id"] + ) + if proof.get("state") != state or not result["can_apply"]: + raise conflict(result["errors"]) + before_version = solution.version + before_snapshot = copy.deepcopy(solution.submission_snapshot) + delete_confirmed_member(member) + solution.invitations.filter(pk__in=pending, status="pending").delete() + if solution.status == "submitted": + after = copy.deepcopy(before_snapshot) + after["members"] = [ + m for m in after["members"] if m["user"]["id"] != data["user_id"] + ] + solution.submission_snapshot = after + solution.version += 1 + solution.updated_at = timezone.now() + # Explicit privileged writer; ordinary save/model/admin guards stay frozen. + ProgramSolution.objects.filter(pk=solution.pk).update( + version=solution.version, + updated_at=solution.updated_at, + submission_snapshot=solution.submission_snapshot, + ) + SolutionAdministrativeChange.objects.create( + solution=solution, + actor=actor, + actor_name=f"{actor.first_name} {actor.last_name}".strip(), + action="remove_member", + before_version=before_version, + after_version=solution.version, + removed_member=copy.deepcopy(result["member"]), + revoked_invitation_ids=pending, + score_count=result["score_count"], + was_submitted=solution.status == "submitted", + before_snapshot=before_snapshot, + after_snapshot=copy.deepcopy(solution.submission_snapshot), + ) + logger.info( + "Program solution member removal program=%s actor=%s solution=%s user=%s", + program.pk, + actor.pk, + solution.pk, + data["user_id"], + ) + return mutation_receipt(solution) diff --git a/partner_programs/services/solution_program_report.py b/partner_programs/services/solution_program_report.py new file mode 100644 index 00000000..4abe97d6 --- /dev/null +++ b/partner_programs/services/solution_program_report.py @@ -0,0 +1,382 @@ +"""The existing safe XLSX writer with actual program-solution identities.""" + +from collections import Counter + +from partner_programs.services.management import participant_rows, person_name +from partner_programs.services.program_report import ( + Report, + ASSIGNMENT_LABELS, + score_value, +) + + +def user_name(user): + return " ".join(filter(None, (user["first_name"], user["last_name"]))) + + +def write_people(report, program): + people = report.sheet( + "Участники", + [ + "ID участника", + "Участник", + "Почта", + "Регион", + "Дата регистрации (МСК)", + "Последняя активность (МСК)", + "Последний вход (МСК)", + ], + ) + answers = report.sheet( + "Анкеты", + ["ID регистрации", "ID участника", "Участник", "ID поля", "Поле анкеты", "Ответ"], + ) + seen = set() + for profile in participant_rows(program).iterator(chunk_size=500): + user = profile.user + if user.pk not in seen: + report.append( + people, + [ + user.pk, + person_name(user), + user.email, + profile.participant_region, + profile.datetime_created, + user.last_activity, + user.last_login, + ], + ) + seen.add(user.pk) + schema = ( + profile.registration_schema + if isinstance(profile.registration_schema, dict) + else {} + ) + fields = { + field["id"]: field.get("label", field["id"]) + for field in schema.get("fields", []) + if isinstance(field, dict) and "id" in field + } + data = ( + profile.partner_program_data + if isinstance(profile.partner_program_data, dict) + else {} + ) + for key, value in data.items(): + report.append( + answers, + [ + profile.pk, + user.pk, + person_name(user), + key, + fields.get(key, key), + value, + ], + ) + + +def write_solutions(report, context, *, submitted_only=False, solution_ids=None): + solutions = report.sheet( + "Решения", + [ + "ID решения", + "ID программы", + "Решение", + "Кейс", + "ID капитана", + "Капитан", + "Размер команды", + "Сдача решения", + "Создано (МСК)", + "Сдано (МСК)", + ], + ) + teams = report.sheet( + "Команды", ["ID решения", "Решение", "ID участника", "Участник", "Роль"] + ) + definitions = report.sheet( + "Поля формы", + ["ID решения", "ID поля", "Поле", "Тип", "Назначение", "Обязательно", "Варианты"], + ) + answers = report.sheet( + "Ответы решения", ["ID решения", "ID поля", "Поле", "Тип", "Ответ"] + ) + for solution in context.solutions: + if solution_ids is not None and solution.pk not in solution_ids: + continue + if submitted_only and solution.status != "submitted": + continue + row = context.solution_row(solution) + name = row["application"]["name"] + team = context.team(solution) + report.append( + solutions, + [ + solution.pk, + context.program.pk, + name, + row["case_name"], + row["captain"]["user_id"], + row["captain"]["full_name"], + len({m["user"]["id"] for m in team}), + ("Сдано" if solution.status == "submitted" else "Не сдано") + if context.program.is_competitive + else "Не требуется", + solution.created_at, + solution.submitted_at, + ], + ) + for member in team: + report.append( + teams, + [ + solution.pk, + name, + member["user"]["id"], + user_name(member["user"]), + member["role"], + ], + ) + fields, values = context.applied_form_and_answers(solution) + for field in fields: + report.append( + definitions, + [ + solution.pk, + field["id"], + field["label"], + field["type"], + field["purpose"], + field["required"], + field["options"], + ], + ) + if field["id"] in values: + report.append( + answers, + [ + solution.pk, + field["id"], + field["label"], + field["type"], + values[field["id"]], + ], + ) + + +def write_evaluation(report, context, *, include_assignments): + if include_assignments: + total = Counter(row["expert"]["expert_id"] for row in context.assignments) + completed = Counter( + row["expert"]["expert_id"] + for row in context.assignments + if row["status"] == "completed" + ) + experts = report.sheet( + "Эксперты", + [ + "ID эксперта", + "ID пользователя", + "Эксперт", + "Почта", + "Назначений", + "Выполнено", + ], + ) + for expert in context.program.experts.select_related("user").order_by("pk"): + report.append( + experts, + [ + expert.pk, + expert.user_id, + person_name(expert.user), + expert.user.email, + total[expert.pk], + completed[expert.pk], + ], + ) + assignments = report.sheet( + "Назначения", + [ + "ID назначения", + "ID решения", + "Решение", + "ID эксперта", + "ID пользователя", + "Эксперт", + "Статус", + "Назначено (МСК)", + "Сдано (МСК)", + ], + ) + for row in context.assignments: + report.append( + assignments, + [ + row["assignment_id"], + row["application"]["solution_id"], + row["application"]["name"], + row["expert"]["expert_id"], + row["expert"]["user_id"], + row["expert"]["full_name"], + ASSIGNMENT_LABELS[row["status"]], + row["assigned_at"], + row["submitted_at"], + ], + ) + criteria_sheet = report.sheet( + "Критерии", ["ID критерия", "Критерий", "Описание", "Тип", "Минимум", "Максимум"] + ) + criteria = {criterion.pk: criterion for criterion in context.criteria} + for criterion in context.criteria: + report.append( + criteria_sheet, + [ + criterion.pk, + criterion.name, + criterion.description, + criterion.type, + criterion.min_value, + criterion.max_value, + ], + ) + scores = report.sheet( + "Оценки", + [ + "ID оценки", + "ID решения", + "Решение", + "ID пользователя эксперта", + "Эксперт", + "ID критерия", + "Критерий", + "Тип", + "Значение", + "Исходное значение", + ], + ) + for score in context.score_records: + criterion = criteria[score["criteria_id"]] + report.append( + scores, + [ + score["pk"], + score["solution_id"], + context.application(context.by_id[score["solution_id"]])["name"], + score["user_id"], + " ".join( + filter( + None, + ( + score["user__first_name"], + score["user__last_name"], + score["user__patronymic"], + ), + ) + ).strip() + or "Без имени", + criterion.pk, + criterion.name, + criterion.type, + score_value(score["value"], criterion.type), + score["value"], + ], + ) + + +def build_solution_program_report(context, kind="program", *, solution_ids=None): + report = Report() + if kind == "program": + overview = context.overview() + summary = report.sheet("Сводка", ["Показатель", "Значение"]) + for row in [ + ["ID программы", context.program.pk], + ["Программа", context.program.name], + ["Сформировано (МСК)", context.now], + ["Описание", context.program.description], + ["Начало программы (МСК)", context.program.datetime_started], + ["Окончание программы (МСК)", context.program.datetime_finished], + ["Окончание регистрации (МСК)", context.program.datetime_registration_ends], + [ + "Окончание приема решений (МСК)", + context.program.get_project_submission_deadline(), + ], + ["Окончание оценивания (МСК)", context.program.datetime_evaluation_ends], + ["Участники", overview["summary"]["participants"]["total"]], + ["Решения", overview["summary"]["solutions"]["total"]], + ["Сдано", overview["solution_funnel"]["submitted"]], + ["Оценено решений", overview["solution_funnel"]["evaluated"]], + [ + "Заходили на платформу", + overview["participant_funnel"]["visited_participants"], + ], + ["Вступили в команду", overview["participant_funnel"]["with_team"]], + [ + "Связи между листами", + "ID решения связывает заявку, команду, поля, ответы, назначения и оценки. " + "ID участника = ID пользователя.", + ], + ]: + report.append(summary, row) + write_people(report, context.program) + cases = report.sheet("Кейсы", ["Кейс", "Решений", "Сдано", "Участников"]) + for row in [ + *overview["cases"]["items"], + dict(name="Без выбранного кейса", **overview["cases"]["without_case"]), + ]: + report.append( + cases, + [ + row["name"], + row["solutions_total"], + row["submitted"], + row["participants_total"], + ], + ) + coverage = report.sheet( + "Заполнение полей", + [ + "ID поля", + "Поле", + "Тип", + "Назначение", + "Применимо к решениям", + "Есть ответ", + "Обязательно для решений", + "Нет обязательного ответа", + ], + ) + for row in overview["field_coverage"]: + report.append( + coverage, + [ + row[key] + for key in ( + "field_id", + "label", + "type", + "purpose", + "applicable", + "answered", + "required_applicable", + "required_unanswered", + ) + ], + ) + activity = report.sheet("Активность", ["Дата", "Регистраций", "Сдано решений"]) + for row in overview["activity"]: + report.append( + activity, [row["date"], row["registrations"], row["submitted_solutions"]] + ) + if kind in ("program", "solutions", "submitted"): + write_solutions( + report, + context, + submitted_only=kind == "submitted", + solution_ids=solution_ids, + ) + if kind in ("program", "scores"): + write_evaluation(report, context, include_assignments=kind == "program") + return report.finish() diff --git a/partner_programs/services/solution_publication.py b/partner_programs/services/solution_publication.py new file mode 100644 index 00000000..6394f73f --- /dev/null +++ b/partner_programs/services/solution_publication.py @@ -0,0 +1,194 @@ +"""Разрешенные публичные материалы без приватной проекции заявки и оценок.""" + +import math +from urllib.parse import urlsplit + +from django.db.models import CharField, Prefetch +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Coalesce + +from partner_programs.solution_api import iso, user_data +from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember +from partner_programs.solution_schema import VALIDATION_DEFAULTS, empty_value, form_data +from project_rates.solution_evaluation import snapshot_name + +OMIT_ANSWER = object() + + +def public_answer_value(field, value): + kind = field["type"] + if kind in ("text", "textarea", "select", "radio"): + return value if isinstance(value, str) else OMIT_ANSWER + if kind == "checkbox": + return value if isinstance(value, bool) else OMIT_ANSWER + if kind == "number": + try: + return ( + value + if type(value) in (int, float) and math.isfinite(value) + else OMIT_ANSWER + ) + except OverflowError: + return OMIT_ANSWER + url = value.get("url") if kind == "file" and isinstance(value, dict) else value + if kind in ("url", "file") and isinstance(url, str): + try: + parsed = urlsplit(url) + if ( + parsed.scheme in ("http", "https") + and parsed.netloc + and not any(ord(char) < 32 or ord(char) == 127 for char in url) + ): + return {"url": url} if kind == "file" else url + except ValueError: + pass + return OMIT_ANSWER + + +def published_solutions(program, search="", *, with_answers=False): + query = ( + ProgramSolution.objects.filter(program=program, published_at__isnull=False) + .select_related("captain") + .prefetch_related( + Prefetch( + "members", queryset=ProgramSolutionMember.objects.select_related("user") + ) + ) + .order_by("published_at", "pk") + ) + if program.is_competitive: + # Marker сам по себе не открывает несданную конкурсную работу. + query = query.filter(status="submitted") + if search: + query = query.annotate( + public_name=Coalesce( + KeyTextTransform("name", "submission_snapshot"), + "name", + output_field=CharField(), + ) + ).filter(public_name__icontains=search) + return query.prefetch_related("answers") if with_answers else query + + +def public_user(value): + if not isinstance(value, dict): + return user_data(value) + return dict( + id=value["id"], + first_name=value.get("first_name") or "", + last_name=value.get("last_name") or "", + avatar_url=value.get("avatar_url") or None, + ) + + +def public_team(solution): + snapshot = solution.submission_snapshot if solution.status == "submitted" else None + captain_id = ( + snapshot.get("captain_id", solution.captain_id) + if snapshot + else solution.captain_id + ) + sources = ( + snapshot.get("members", []) + if snapshot + else [ + dict(user=member.user, role=member.role, specialization=member.specialization) + for member in solution.members.all() + ] + ) + people = {} + for source in sources: + user = public_user(source["user"]) + people[user["id"]] = dict( + user=user, + role=source.get("role"), + specialization=source.get("specialization"), + is_captain=user["id"] == captain_id, + ) + people.setdefault( + captain_id, + dict( + user=public_user(solution.captain), + role=None, + specialization=None, + is_captain=True, + ), + ) + members = [ + people[captain_id], + *[row for uid, row in people.items() if uid != captain_id], + ] + return dict(members=members, accepted_count=len(members)) + + +def application(solution): + return dict( + kind="solution", + program_id=solution.program_id, + solution_id=solution.pk, + name=snapshot_name(solution), + ) + + +def public_card(solution): + team = public_team(solution) + return dict( + application=application(solution), + published_at=iso(solution.published_at), + captain=team["members"][0]["user"], + accepted_count=team["accepted_count"], + ) + + +def public_fields(fields): + keys = ( + "id", + "type", + "purpose", + "label", + "help_text", + "required", + "position", + "options", + ) + return [ + dict( + **{key: field[key] for key in keys}, + validation={ + key: field.get("validation", {}).get(key, value) + for key, value in VALIDATION_DEFAULTS.items() + }, + ) + for field in fields + ] + + +def public_materials(solution): + snapshot = solution.submission_snapshot if solution.status == "submitted" else None + if snapshot: + fields, answers = snapshot["form"]["fields"], snapshot["answers"] + else: + fields = form_data(solution.program)["fields"] + answers = [ + dict(field_id=row.field_id, value=row.value) for row in solution.answers.all() + ] + fields = public_fields(fields) + by_id = {row["id"]: row for row in fields} + safe_answers = {} + for row in answers: + field = by_id.get(row["field_id"]) + if field is None or empty_value(row["value"]): + continue + value = public_answer_value(field, row["value"]) + if value is OMIT_ANSWER: + continue + safe_answers[row["field_id"]] = dict(field_id=row["field_id"], value=value) + return dict( + application=application(solution), + published_at=iso(solution.published_at), + fields=fields, + answers=[ + safe_answers[field["id"]] for field in fields if field["id"] in safe_answers + ], + team=public_team(solution), + ) diff --git a/partner_programs/services/solution_registration_actions.py b/partner_programs/services/solution_registration_actions.py new file mode 100644 index 00000000..81558af5 --- /dev/null +++ b/partner_programs/services/solution_registration_actions.py @@ -0,0 +1,278 @@ +"""Confirmed registration removal, with retained work and program-scoped audit.""" + +import copy +import hashlib +import json + +from django.core import signing +from django.db import transaction +from django.db.models import Q +from django.http import Http404 +from django.utils import timezone + +from invites.models import Invite +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.permissions import can_manage_program +from partner_programs.services import solution_archive_actions as archive +from partner_programs.services.solution_member_actions import delete_confirmed_member +from partner_programs.solution_api import solution_data, user_data +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_models import ( + ProgramSolution, + SolutionAdministrativeChange, + SolutionRegistrationRemoval, +) +from partner_programs.solution_team import program_for +from projects.models import Collaborator + +SALT = "solution-registration-removal-v1" +MAX_AGE = 600 + + +def managed_program(program_id, actor, *, lock=False): + program = program_for(program_id, lock=lock) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", "Действие доступно организатору программы.", status=403 + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + return program + + +def context(program_id, actor, ids, *, applying=False): + program = managed_program(program_id, actor, lock=True) + if ( + not isinstance(ids, list) + or not 1 <= len(ids) <= 10000 + or any(type(pk) is not int or not 0 < pk <= MAX_ID for pk in ids) + or len(set(ids)) != len(ids) + ): + raise SolutionError( + "validation_error", "Выберите регистрации без повторов.", status=400 + ) + profiles = list( + PartnerProgramUserProfile.objects.select_for_update(of=("self",)) + .filter(partner_program=program, pk__in=ids, user__isnull=False) + .select_related("user") + .order_by("pk") + ) + if len(profiles) != len(ids): + if applying: + raise archive.conflict() + raise Http404 + users = [p.user_id for p in profiles] + legacy = PartnerProgramProject.objects.filter(partner_program=program).filter( + Q(project__leader_id__in=users) + | Q( + project_id__in=Collaborator.objects.filter(user_id__in=users).values( + "project_id" + ) + ) + | Q(project_id__in=Invite.objects.filter(user_id__in=users).values("project_id")) + ) + if any(p.project_id is not None for p in profiles) or legacy.exists(): + raise SolutionError( + "legacy_application_conflict", + "Регистрация связана с заявкой проекта. Удаление отменено для сохранения данных.", + ) + ids = list( + ProgramSolution.objects.filter(program=program) + .filter( + Q(captain_id__in=users) + | Q(members__user_id__in=users) + | Q(invitations__recipient_id__in=users, invitations__status="pending") + ) + .order_by("pk") + .values_list("pk", flat=True) + .distinct() + ) + if not ids: + return program, profiles, [], [] + _, rows, files = archive.context(program_id, actor, ids) + return program, profiles, rows, files + + +def inspect(program, profiles, rows, actor, files): + users = {p.user_id for p in profiles} + registrations = [dict(registration_id=p.pk, user=user_data(p.user)) for p in profiles] + effects, errors = [], [] + for solution in rows: + data = solution_data(solution, actor) + captain = solution.captain_id in users + removed = [m for m in data["team"]["members"] if m["user"]["id"] in users] + actual = set( + solution.members.filter(user_id__in=users).values_list("user_id", flat=True) + ) + if not captain and actual != {m["user"]["id"] for m in removed}: + errors.append( + issue( + "registration_ids", + "team_state_changed", + "Состав решения требует проверки.", + ) + ) + pending = solution.invitations.filter(status="pending") + effects.append( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=data["name"], + ), + solution_version=solution.version, + is_submitted=solution.status == "submitted", + effect="archive" + if captain + else "remove_members" + if removed + else "revoke_invitations", + removed_members=[] if captain else removed, + removed_assignments_count=solution.expert_assignments.count() + if captain + else 0, + revoked_invitation_count=( + pending if captain else pending.filter(recipient_id__in=users) + ).count(), + score_count=solution.scores.count(), + ) + ) + _, work_state = archive.inspect(program, rows, actor, files) + state = hashlib.sha256( + json.dumps( + dict( + work=work_state, + effects=effects, + people=registrations, + registrations=list( + PartnerProgramUserProfile.objects.filter( + pk__in=[p.pk for p in profiles] + ) + .order_by("pk") + .values() + ), + ), + sort_keys=True, + default=str, + ).encode() + ).hexdigest() + result = dict( + registrations=registrations, + effects=effects, + deleted_count=len(profiles), + archived_count=sum(e["effect"] == "archive" for e in effects), + removed_member_count=sum(len(e["removed_members"]) for e in effects), + removed_assignments_count=sum(e["removed_assignments_count"] for e in effects), + revoked_invitation_count=sum(e["revoked_invitation_count"] for e in effects), + score_count=sum(e["score_count"] for e in effects), + can_apply=not errors, + errors=errors, + ) + return result, state + + +def proof_context(program, actor, ids): + return dict(program=program.pk, actor=actor.pk, registration_ids=sorted(ids)) + + +@transaction.atomic +def preview(program_id, actor, data): + program, profiles, rows, files = context(program_id, actor, data["registration_ids"]) + result, state = inspect(program, profiles, rows, actor, files) + result["preview_token"] = ( + signing.dumps( + {**proof_context(program, actor, data["registration_ids"]), "state": state}, + salt=SALT, + compress=True, + ) + if result["can_apply"] + else None + ) + return result + + +@transaction.atomic +def apply(program_id, actor, data): + program, profiles, rows, files = context( + program_id, actor, data["registration_ids"], applying=True + ) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=MAX_AGE) + except signing.BadSignature as error: + raise archive.conflict() from error + expected = proof_context(program, actor, data["registration_ids"]) + if not isinstance(proof, dict) or any(proof.get(k) != v for k, v in expected.items()): + raise archive.conflict() + result, state = inspect(program, profiles, rows, actor, files) + if not result["can_apply"] or proof.get("state") != state: + raise archive.conflict() + audit = SolutionRegistrationRemoval.objects.create( + program=program, + actor=actor, + actor_name=f"{actor.first_name} {actor.last_name}".strip(), + registrations=copy.deepcopy(result["registrations"]), + deleted_count=len(profiles), + ) + users, now, items = {p.user_id for p in profiles}, timezone.now(), [] + for solution, effect in zip(rows, result["effects"]): + if effect["effect"] == "archive": + item, change = archive.archive_locked( + solution, + actor, + now, + reason="registration_removed", + registration_removal=audit, + ) + else: + before = copy.deepcopy(solution.submission_snapshot) + for member in solution.members.filter(user_id__in=users).order_by("pk"): + delete_confirmed_member(member) + pending = list( + solution.invitations.filter(recipient_id__in=users, status="pending") + .order_by("pk") + .values_list("pk", flat=True) + ) + solution.invitations.filter(pk__in=pending, status="pending").delete() + after = copy.deepcopy(before) + if after is not None: + after["members"] = [ + m for m in after["members"] if m["user"]["id"] not in users + ] + ProgramSolution.objects.filter(pk=solution.pk).update( + submission_snapshot=after, + version=solution.version + 1, + updated_at=now, + ) + change = SolutionAdministrativeChange.objects.create( + solution=solution, + actor=actor, + actor_name=audit.actor_name, + action="remove_registrations", + registration_removal=audit, + before_version=solution.version, + after_version=solution.version + 1, + removed_members=copy.deepcopy(effect["removed_members"]), + revoked_invitation_ids=pending, + score_count=effect["score_count"], + was_submitted=solution.status == "submitted", + before_snapshot=before, + after_snapshot=after, + ) + item = dict( + application=effect["application"], solution_version=solution.version + 1 + ) + items.append( + dict( + application=item["application"], + solution_version=item["solution_version"], + change_id=change.pk, + action=change.action, + ) + ) + PartnerProgramUserProfile.objects.filter( + partner_program=program, pk__in=data["registration_ids"] + ).delete() + return dict(audit_id=audit.pk, deleted_count=len(profiles), items=items) diff --git a/partner_programs/services/solution_role_analytics.py b/partner_programs/services/solution_role_analytics.py new file mode 100644 index 00000000..d041eb73 --- /dev/null +++ b/partner_programs/services/solution_role_analytics.py @@ -0,0 +1,164 @@ +"""Read-only widget data owned by the solution, without a Project projection.""" + +from collections import defaultdict + +from django.db.models import Count, Q +from rest_framework.exceptions import APIException + +from partner_programs.services.analytics import _get_participant_metrics +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.solution_models import ProgramSolution +from project_rates.evaluation_math import complete_values +from project_rates.models import Criteria +from project_rates.solution_evaluation import snapshot_case, snapshot_name +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore + + +class ParticipantSolutionIntegrityError(APIException): + status_code = 409 + default_detail = "Связи заявки требуют проверки организатором." + default_code = "participant_solution_integrity_error" + + +def criteria_for(program): + return list( + Criteria.objects.filter(partner_program=program).only( + "pk", "type", "min_value", "max_value" + ) + ) + + +def solution_stage(program, solution): + """Same participant stages as the current distributed/open analytics.""" + if not program.is_competitive: + return "not_applicable" + if solution.status != "submitted": + return "not_submitted" + scores = SolutionScore.objects.filter( + solution=solution, criteria__partner_program=program + ) + if not program.is_distributed_evaluation: + return "evaluated" if scores.exists() else "submitted" + assigned_users = list( + SolutionExpertAssignment.objects.filter( + partner_program=program, solution=solution + ).values_list("expert__user_id", flat=True) + ) + values = defaultdict(dict) + for user_id, criterion_id, value in scores.values_list( + "user_id", "criteria_id", "value" + ): + values[user_id][criterion_id] = value + criteria = criteria_for(program) + completed = sum(complete_values(criteria, values[user]) for user in assigned_users) + required = program.required_evaluators or len(assigned_users) + if assigned_users and completed and completed >= required: + return "evaluated" + return "review" if assigned_users or values else "submitted" + + +def participant_metrics(program, user): + from partner_programs.services.role_analytics import participant_project_links + + # Read ownership and the row together; no first-match or second unscoped lookup. + owned = list( + ProgramSolution.objects.filter(program=program) + .filter(Q(captain=user) | Q(members__user=user)) + .distinct() + .prefetch_related("answers") + .order_by("created_at", "pk")[:2] + ) + if len(owned) > 1 or participant_project_links(program, user).exists(): + raise ParticipantSolutionIntegrityError() + result = dict( + application=None, + case_provided=False, + case_name=None, + stage="none" if program.is_competitive else "not_applicable", + submission_open=program.is_project_submission_open(), + ) + if not owned: + result["case_provided"] = get_program_case_field(program) is not None + return result + solution = owned[0] + result["application"] = dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=snapshot_name(solution), + ) + if solution.status == "submitted": + result["case_provided"] = any( + field["purpose"] == "case" + for field in (solution.submission_snapshot or {}) + .get("form", {}) + .get("fields", []) + ) + result["case_name"] = snapshot_case(solution) + else: + field = get_program_case_field(program) + result["case_provided"] = field is not None + result["case_name"] = snapshot_case( + solution, draft_field_id=field.pk if field else None + ) + result["stage"] = solution_stage(program, solution) + return result + + +def organizer_metrics(program): + participants = _get_participant_metrics(program.pk) + solutions = program.solutions.aggregate( + created=Count("pk"), submitted=Count("pk", filter=Q(status="submitted")) + ) + return dict( + participants=participants["unique_participants"], + solutions=solutions["created"], + submitted_solutions=solutions["submitted"] if program.is_competitive else None, + participants_without_solution=participants["without_team"], + ) + + +def expert_metrics(program, user): + mode = "distributed" if program.is_distributed_evaluation else "open" + if mode == "open" or not program.is_competitive: + return dict(mode=mode, assigned=None, remaining=None) + assignments = list( + SolutionExpertAssignment.objects.filter( + partner_program=program, expert__user=user, solution__archived_at__isnull=True + ).values("solution_id", "solution__program_id", "solution__status") + ) + readable = { + row["solution_id"] + for row in assignments + if row["solution__program_id"] == program.pk + and row["solution__status"] == "submitted" + } + criteria = criteria_for(program) + values = defaultdict(dict) + for solution_id, criterion_id, value in SolutionScore.objects.filter( + solution_id__in=readable, user=user, criteria__partner_program=program + ).values_list("solution_id", "criteria_id", "value"): + values[solution_id][criterion_id] = value + completed = sum(complete_values(criteria, values[pk]) for pk in readable) + return dict( + mode=mode, assigned=len(assignments), remaining=len(assignments) - completed + ) + + +def build_solution_role_widget(program, user, role): + result = dict( + program_id=program.pk, + storage_mode="solution", + role=role, + is_competitive=program.is_competitive, + ) + if role == "organizer": + result[role] = organizer_metrics(program) + elif role == "expert": + result[role] = { + **expert_metrics(program, user), + "evaluation_ends": program.datetime_evaluation_ends, + } + else: + result[role] = participant_metrics(program, user) + return result diff --git a/partner_programs/services/solution_submission_actions.py b/partner_programs/services/solution_submission_actions.py new file mode 100644 index 00000000..de9945dd --- /dev/null +++ b/partner_programs/services/solution_submission_actions.py @@ -0,0 +1,226 @@ +"""Existing organizer first-submission exception, bound to a signed preview.""" + +import hashlib +import json +import logging + +from django.core import signing +from django.db import transaction +from django.http import Http404 + +from files.models import UserFile +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.permissions import can_manage_program +from partner_programs.solution_api import user_data +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember +from partner_programs.solution_schema import form_data +from partner_programs.solution_submission import _finalize_submission, readiness_issues +from partner_programs.solution_team import program_for +from project_rates.solution_evaluation import snapshot_name + +SALT = "solution-submission-actions-v1" +MAX_AGE = 600 +logger = logging.getLogger(__name__) + + +def conflict(): + return SolutionError( + "solution_action_conflict", + "Данные решений изменились. Проверьте их и подтвердите действие заново.", + ) + + +def context(program_id, actor, ids): + program = program_for(program_id, lock=True) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", + "Действие доступно организатору программы.", + status=403, + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + if not program.is_competitive: + raise SolutionError( + "not_competitive", "В программе нет сдачи конкурсных решений." + ) + rows = list( + ProgramSolution.objects.select_for_update(of=("self",)) + .filter(program=program, pk__in=ids) + .select_related("captain") + .prefetch_related("answers", "members__user", "invitations") + .order_by("pk") + ) + if len(rows) != len(ids): + raise Http404 + list(program.fields.select_for_update().order_by("pk")) + list( + ProgramSolutionMember.objects.select_for_update() + .filter(solution_id__in=ids) + .order_by("pk") + ) + urls = { + answer.value["url"] + for row in rows + for answer in row.answers.all() + if isinstance(answer.value, dict) and isinstance(answer.value.get("url"), str) + } + # File metadata can have writers outside the solution commands. Bind the + # preview fingerprint under the same locks held until snapshot commit. + list(UserFile.objects.select_for_update().filter(pk__in=urls).order_by("pk")) + for row in rows: + row.program = program + return program, rows + + +def inspect(program, rows): + form = form_data(program) + items, state, ready, already = [], [], 0, 0 + users, urls = set(), set() + for solution in rows: + answers = [ + dict(field_id=row.field_id, value=row.value) + for row in sorted(solution.answers.all(), key=lambda row: row.field_id) + ] + members = [ + dict( + user=user_data(member.user), + role=member.role, + specialization=member.specialization, + joined_at=member.joined_at, + ) + for member in sorted(solution.members.all(), key=lambda row: row.pk) + ] + accepted = [member["user"]["id"] for member in members] + users.update(accepted) + urls.update( + row["value"]["url"] + for row in answers + if isinstance(row["value"], dict) and isinstance(row["value"].get("url"), str) + ) + errors = ( + [] + if solution.status == "submitted" + else readiness_issues(solution, form, answers, accepted) + ) + items.append( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=snapshot_name(solution), + ), + errors=errors, + ) + ) + already += int(solution.status == "submitted") + ready += int(solution.status == "draft" and not errors) + state.append( + dict( + id=solution.pk, + version=solution.version, + name=solution.name, + captain=solution.captain_id, + status=solution.status, + submitted_at=solution.submitted_at, + snapshot=solution.submission_snapshot, + answers=answers, + members=members, + invitations=[ + dict( + id=row.pk, + version=row.version, + status=row.status, + recipient=row.recipient_id, + ) + for row in sorted(solution.invitations.all(), key=lambda row: row.pk) + ], + ) + ) + fingerprint = dict( + program=program.pk, + form=form, + minimum=program.legacy_team_min_size, + maximum=program.legacy_team_max_size, + solutions=state, + registrations=list( + PartnerProgramUserProfile.objects.filter( + partner_program=program, user_id__in=users + ) + .order_by("pk") + .values("id", "user_id") + ), + files=list( + UserFile.objects.filter(pk__in=urls) + .order_by("pk") + .values("link", "user_id", "size", "extension", "detected_mime_type") + ), + ) + digest = hashlib.sha256( + json.dumps(fingerprint, sort_keys=True, default=str).encode() + ).hexdigest() + result = dict( + items=items, + ready_count=ready, + already_submitted_count=already, + can_apply=ready > 0 and not any(item["errors"] for item in items), + ) + return result, digest, form + + +def proof_context(program, actor, data): + return dict( + actor=actor.pk, program=program.pk, ids=data["ids"], action=data["action"] + ) + + +@transaction.atomic +def preview(program_id, actor, data): + program, rows = context(program_id, actor, data["ids"]) + result, state, _ = inspect(program, rows) + result["preview_token"] = ( + signing.dumps( + {**proof_context(program, actor, data), "state": state}, + salt=SALT, + compress=True, + ) + if result["can_apply"] + else None + ) + return result + + +@transaction.atomic +def apply(program_id, actor, data): + program, rows = context(program_id, actor, data["ids"]) + try: + proof = signing.loads(data["preview_token"], salt=SALT, max_age=MAX_AGE) + except signing.BadSignature as error: + raise conflict() from error + if not isinstance(proof, dict) or any( + proof.get(key) != value + for key, value in proof_context(program, actor, data).items() + ): + raise conflict() + result, state, form = inspect(program, rows) + if proof.get("state") != state or not result["can_apply"]: + raise conflict() + for solution in rows: + if solution.status == "draft": + _finalize_submission(solution, actor, form, allow_after_deadline=True) + logger.info( + "Program solution submission action program=%s actor=%s ids=%s changed=%s already=%s", + program.pk, + actor.pk, + data["ids"], + result["ready_count"], + result["already_submitted_count"], + ) + return dict( + changed_count=result["ready_count"], + already_submitted_count=result["already_submitted_count"], + ) diff --git a/partner_programs/solution_action_api.py b/partner_programs/solution_action_api.py new file mode 100644 index 00000000..c0050e8b --- /dev/null +++ b/partner_programs/solution_action_api.py @@ -0,0 +1,55 @@ +"""C00 organizer preview/confirmation for first solution submission.""" + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.services import solution_submission_actions +from partner_programs.solution_api import ( + CommandInteger, + SolutionAPIView, + StrictSerializer, +) +from partner_programs.solution_errors import MAX_ID + + +class SolutionActionPreviewSerializer(StrictSerializer): + action = serializers.ChoiceField(choices=("submit",)) + ids = serializers.ListField( + child=CommandInteger(min_value=1, max_value=MAX_ID), + allow_empty=False, + max_length=10000, + ) + + def validate_ids(self, ids): + return sorted(set(ids)) + + +class SolutionActionApplySerializer(SolutionActionPreviewSerializer): + preview_token = serializers.CharField( + max_length=200000, trim_whitespace=False, allow_blank=False + ) + + +class SolutionActionView(SolutionAPIView): + apply_action = False + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def post(self, request, pk): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = ( + SolutionActionApplySerializer + if self.apply_action + else SolutionActionPreviewSerializer + )(data=request.data) + serializer.is_valid(raise_exception=True) + command = ( + solution_submission_actions.apply + if self.apply_action + else solution_submission_actions.preview + ) + return Response(command(pk, request.user, serializer.validated_data)) diff --git a/partner_programs/solution_admin.py b/partner_programs/solution_admin.py new file mode 100644 index 00000000..3805e2d0 --- /dev/null +++ b/partner_programs/solution_admin.py @@ -0,0 +1,166 @@ +from django.contrib import admin, messages +from django.db import transaction +from django.db.models import Q + +from partner_programs.models import PartnerProgram +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + ProgramSolutionAnswer, + SolutionInvitation, + SolutionAdministrativeChange, + SolutionRegistrationRemoval, +) + + +class ReadonlySolutionInline(admin.TabularInline): + extra = 0 + can_delete = False + + def has_add_permission(self, request, obj=None): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(SolutionAdministrativeChange) +class SolutionAdministrativeChangeAdmin(admin.ModelAdmin): + list_display = ("id", "solution", "action", "actor_name", "created_at") + list_filter = ("action", "was_submitted", "solution__program") + search_fields = ("solution__name", "actor_name") + readonly_fields = tuple( + field.name for field in SolutionAdministrativeChange._meta.fields + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(SolutionRegistrationRemoval) +class SolutionRegistrationRemovalAdmin(SolutionAdministrativeChangeAdmin): + list_display = ("id", "program", "actor_name", "deleted_count", "created_at") + list_filter = ("program",) + search_fields = ("actor_name",) + readonly_fields = tuple( + field.name for field in SolutionRegistrationRemoval._meta.fields + ) + + +class SolutionMemberInline(ReadonlySolutionInline): + model = ProgramSolutionMember + verbose_name = "Участник команды" + verbose_name_plural = "Принятый состав" + fields = ("user", "role", "specialization", "joined_at") + readonly_fields = fields + + +class SolutionAnswerInline(ReadonlySolutionInline): + model = ProgramSolutionAnswer + verbose_name = "Ответ" + verbose_name_plural = "Ответы решения" + fields = ("field", "value") + readonly_fields = fields + + +class SolutionInvitationInline(ReadonlySolutionInline): + model = SolutionInvitation + verbose_name = "Приглашение" + verbose_name_plural = "Приглашения команды" + fields = ("recipient", "role", "status", "created_at") + readonly_fields = fields + + +@admin.register(ProgramSolution) +class ProgramSolutionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "program", + "captain", + "status", + "submitted_at", + "archived_at", + ) + list_filter = ("program", "status") + search_fields = ("name", "program__name", "captain__email") + inlines = (SolutionMemberInline, SolutionAnswerInline, SolutionInvitationInline) + + def get_queryset(self, request): + return ProgramSolution.all_objects.all() + + def has_change_permission(self, request, obj=None): + return super().has_change_permission(request, obj) and ( + obj is None or obj.archived_at is None + ) + + def has_add_permission(self, request): + return False + + @transaction.atomic + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): + if request.method == "POST" and object_id: + obj = self.get_object(request, object_id) + if obj: + PartnerProgram.objects.select_for_update().get(pk=obj.program_id) + return super().changeform_view(request, object_id, form_url, extra_context) + + def save_model(self, request, obj, form, change): + if change and obj.status == "draft" and "name" in form.changed_data: + obj.version += 1 + return super().save_model(request, obj, form, change) + + def get_readonly_fields(self, request, obj=None): + return tuple( + field.name + for field in self.model._meta.fields + if not obj + or obj.status == "submitted" + or obj.archived_at + or field.name != "name" + ) + + def has_delete_permission(self, request, obj=None): + return super().has_delete_permission(request, obj) and ( + obj is None or (obj.status != "submitted" and obj.archived_at is None) + ) + + def get_deleted_objects(self, objs, request): + deleted, counts, needed, protected = super().get_deleted_objects(objs, request) + protected.extend( + f"Сохраненное решение #{obj.pk}" + for obj in objs + if obj.status == "submitted" or obj.archived_at + ) + return deleted, counts, needed, protected + + @transaction.atomic + def delete_view(self, request, object_id, extra_context=None): + obj = self.get_object(request, object_id) + if obj: + PartnerProgram.objects.select_for_update().get(pk=obj.program_id) + return super().delete_view(request, object_id, extra_context) + + @transaction.atomic + def delete_queryset(self, request, queryset): + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=queryset.values("program_id")) + .order_by("pk") + ) + submitted = queryset.filter( + Q(status="submitted") | Q(archived_at__isnull=False) + ).count() + queryset.filter(status="draft", archived_at__isnull=True).delete() + if submitted: + self.message_user( + request, + f"Защищенные решения сохранены: {submitted}.", + level=messages.WARNING, + ) diff --git a/partner_programs/solution_analytics_api.py b/partner_programs/solution_analytics_api.py new file mode 100644 index 00000000..37081bdd --- /dev/null +++ b/partner_programs/solution_analytics_api.py @@ -0,0 +1,284 @@ +"""Private aggregate APIs for the solution source, independent of Project DTOs.""" + +from datetime import date, datetime + +from django.http import Http404, HttpResponse +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.permissions import can_manage_program +from partner_programs.services.solution_case_analytics import ( + build_solution_case_analytics, +) +from partner_programs.services.solution_analytics import SolutionAnalyticsContext +from partner_programs.solution_api import SolutionAPIView, StrictSerializer +from partner_programs.solution_errors import MAX_ID, SolutionError +from partner_programs.solution_team import program_for +from project_rates.solution_models import SolutionExpertAssignment + + +def wire_data(value): + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, dict): + return {key: wire_data(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [wire_data(item) for item in value] + return value + + +class SolutionAnalyticsAPIView(SolutionAPIView): + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + if hasattr(response, "data"): + response.data = wire_data(response.data) + return response + + def managed_program(self, request, pk): + program = program_for(pk) + if not can_manage_program(request.user, program): + raise SolutionError( + "permission_denied", + "Аналитика доступна организатору программы.", + status=403, + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", + "Программа использует заявки на основе проектов.", + ) + return program + + def query(self, request, serializer=StrictSerializer): + query = serializer(data=request.query_params) + query.is_valid(raise_exception=True) + return query.validated_data + + +class SolutionCaseAnalyticsView(SolutionAnalyticsAPIView): + def get(self, request, pk): + program = self.managed_program(request, pk) + self.query(request) + return Response(build_solution_case_analytics(program)) + + +class SolutionManagerAnalyticsView(SolutionAnalyticsAPIView): + def get(self, request, pk): + program = self.managed_program(request, pk) + self.query(request) + return Response(SolutionAnalyticsContext(program).overview()) + + +class ManagerAssignmentQuery(StrictSerializer): + scope = serializers.ChoiceField( + choices=("all", "completed", "pending"), default="all" + ) + + +class SolutionManagerAssignmentsView(SolutionAnalyticsAPIView): + def get(self, request, pk): + program = self.managed_program(request, pk) + query = self.query(request, ManagerAssignmentQuery) + context = SolutionAnalyticsContext(program) + rows = context.assignments + if query["scope"] != "all": + rows = [ + row + for row in rows + if (row["status"] == "completed") == (query["scope"] == "completed") + ] + return Response( + dict( + applicable=program.is_competitive and program.is_distributed_evaluation, + mode="distributed" if program.is_distributed_evaluation else "open", + results=rows, + ) + ) + + +class SolutionManagerAssignmentView(SolutionAnalyticsAPIView): + def get(self, request, pk, assignment_id): + program = self.managed_program(request, pk) + self.query(request) + if not 0 < assignment_id <= MAX_ID: + raise Http404 + solution_id = ( + SolutionExpertAssignment.objects.filter( + pk=assignment_id, partner_program=program, solution__program=program + ) + .values_list("solution_id", flat=True) + .first() + ) + if solution_id is None: + raise Http404 + context = SolutionAnalyticsContext(program, solution_ids=[solution_id]) + assignment = next( + (row for row in context.assignments if row["assignment_id"] == assignment_id), + None, + ) + if assignment is None: + raise Http404 + return Response(dict(**assignment, scores=context.assignment_scores(assignment))) + + +class ManagerSolutionFilters(StrictSerializer): + search = serializers.CharField(allow_blank=True, max_length=200, default="") + scope = serializers.ChoiceField( + choices=("all", "not_submitted", "awaiting_evaluation"), default="all" + ) + submission = serializers.ChoiceField( + choices=("all", "submitted", "not_submitted"), default="all" + ) + case_scope = serializers.ChoiceField( + choices=("all", "selected", "without_case"), default="all" + ) + case_name = serializers.CharField( + max_length=500, required=False, trim_whitespace=False + ) + field_id = serializers.IntegerField(min_value=1, max_value=MAX_ID, required=False) + answer_state = serializers.ChoiceField( + choices=("all", "present", "missing", "not_applicable"), default="all" + ) + + def validate(self, attrs): + for key in ("field_id", "answer_state"): + if key in self.initial_data and self.initial_data[key] == "": + raise serializers.ValidationError({key: "Укажите значение фильтра."}) + if attrs["answer_state"] != "all" and "field_id" not in attrs: + raise serializers.ValidationError( + dict(field_id="Выберите поле формы для фильтра ответа.") + ) + return attrs + + +class ManagerSolutionsQuery(ManagerSolutionFilters): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + + +def filtered_solution_rows(context, query): + """Одна выборка для JSON и всех листов выгрузки найденных решений.""" + from partner_programs.solution_schema import empty_value + + if ( + query["case_scope"] == "selected" + and query.get("case_name") not in context.options + ): + raise serializers.ValidationError( + dict(case_name="Укажите действующий кейс программы.") + ) + field_id = query.get("field_id") + if field_id is not None: + known = {field["id"] for field in context.form["fields"]} + for solution in context.solutions: + if solution.status == "submitted": + fields, _ = context.applied_form_and_answers(solution) + known.update(field["id"] for field in fields) + if field_id not in known: + raise serializers.ValidationError( + dict(field_id="Выберите поле формы этой программы.") + ) + rows = [] + for solution in context.solutions: + row = context.solution_row(solution) + if query["scope"] != "all": + states = ( + ("not_submitted",) + if query["scope"] == "not_submitted" + else ("awaiting_evaluation", "partially_evaluated") + ) + if row["evaluation_status"] not in states: + continue + if query["submission"] != "all": + state = "submitted" if query["submission"] == "submitted" else "draft" + if row["submission_status"] != state: + continue + if query["case_scope"] == "selected" and row["case_name"] != query["case_name"]: + continue + if query["case_scope"] == "without_case" and row["case_name"] is not None: + continue + if query["search"].lower() not in row["application"]["name"].lower(): + continue + if field_id is not None and query["answer_state"] != "all": + fields, answers = context.applied_form_and_answers(solution) + applicable = any(field["id"] == field_id for field in fields) + answer_state = ( + "not_applicable" + if not applicable + else "missing" + if empty_value(answers.get(field_id)) + else "present" + ) + if answer_state != query["answer_state"]: + continue + rows.append((solution, row)) + return rows + + +class SolutionManagerSolutionsView(SolutionAnalyticsAPIView): + def get(self, request, pk): + program = self.managed_program(request, pk) + query = self.query(request, ManagerSolutionsQuery) + context = SolutionAnalyticsContext(program) + rows = [row for _, row in filtered_solution_rows(context, query)] + count, offset, limit = len(rows), query["offset"], query["limit"] + end = offset + limit + + def link(start): + params = request.query_params.copy() + params["offset"], params["limit"] = start, limit + return request.build_absolute_uri("?" + params.urlencode()) + + return Response( + dict( + count=count, + next=link(offset + limit) if offset + limit < count else None, + previous=link(max(0, offset - limit)) if offset else None, + submission_applicable=program.is_competitive, + mode="distributed" if program.is_distributed_evaluation else "open", + results=rows[offset:end], + ) + ) + + +class ManagerExportQuery(ManagerSolutionFilters): + kind = serializers.ChoiceField( + choices=("program", "solutions", "submitted", "scores"), default="program" + ) + + def validate(self, attrs): + attrs = super().validate(attrs) + if attrs["kind"] in ("program", "scores") and any( + value not in (None, "", "all") + for key, value in attrs.items() + if key != "kind" + ): + raise serializers.ValidationError( + dict(kind="Фильтры доступны для выгрузки решений или сданных решений.") + ) + return attrs + + +class SolutionManagerExportView(SolutionAnalyticsAPIView): + def get(self, request, pk): + from partner_programs.services.solution_program_report import ( + build_solution_program_report, + ) + + program = self.managed_program(request, pk) + query = self.query(request, ManagerExportQuery) + context = SolutionAnalyticsContext(program) + selected_ids = ( + {solution.pk for solution, _ in filtered_solution_rows(context, query)} + if query["kind"] in ("solutions", "submitted") + else None + ) + response = HttpResponse( + build_solution_program_report( + context, query["kind"], solution_ids=selected_ids + ), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = 'attachment; filename="program-solutions.xlsx"' + return response diff --git a/partner_programs/solution_answer_api.py b/partner_programs/solution_answer_api.py new file mode 100644 index 00000000..b82b8da8 --- /dev/null +++ b/partner_programs/solution_answer_api.py @@ -0,0 +1,74 @@ +import math + +from django.db import transaction +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.solution_api import ( + CommandInteger, + NameField, + StrictSerializer, + solution_data, +) +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_answers import update_solution + + +class AnswerValueField(serializers.Field): + def to_internal_value(self, value): + if isinstance(value, (str, bool)): + return value + if isinstance(value, (int, float)): + try: + if math.isfinite(value): + return value + except OverflowError: + pass + if ( + isinstance(value, dict) + and set(value) == {"url"} + and isinstance(value["url"], str) + ): + return value + raise serializers.ValidationError( + "Ожидается текст, число, логическое значение или один файл." + ) + + def to_representation(self, value): + return value + + +class AnswerPatchSerializer(StrictSerializer): + field_id = CommandInteger(min_value=1, max_value=MAX_ID) + value = AnswerValueField(allow_null=True) + + +class UpdateSolutionSerializer(StrictSerializer): + expected_version = CommandInteger(min_value=1) + expected_form_revision = serializers.RegexField( + r"^[a-f0-9]{64}$", trim_whitespace=False + ) + name = NameField(min_length=1, max_length=256, required=False, trim_whitespace=False) + answers = AnswerPatchSerializer(many=True, required=False) + + def validate(self, data): + if not ({"name", "answers"} & data.keys()): + raise serializers.ValidationError( + {"fields": "Укажите название или изменяемые ответы."} + ) + return data + + +@transaction.atomic +def patch_solution(request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = UpdateSolutionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + solution = update_solution( + program_id=pk, + solution_id=solution_id, + actor=request.user, + **serializer.validated_data + ) + return Response(solution_data(solution, request.user)) diff --git a/partner_programs/solution_answers.py b/partner_programs/solution_answers.py new file mode 100644 index 00000000..ed2ac422 --- /dev/null +++ b/partner_programs/solution_answers.py @@ -0,0 +1,115 @@ +"""Частичное сохранение черновика под общими блокировками программы и решения.""" + +from django.db import transaction + +from files.models import UserFile +from partner_programs.solution_errors import SolutionError, issue +from partner_programs.solution_models import ProgramSolutionAnswer +from partner_programs.solution_schema import form_data, value_problem +from partner_programs.solution_team import ( + lock_solution, + require_captain, + require_mutable, + require_version, + advance_version, +) +from partner_programs.solution_files import owned_file_urls, validate_file + +UNSET = object() +TEXT_TYPES = ("text", "textarea", "url", "select", "radio") + + +def cleared_value(field, value): + return value is None or ( + field["type"] in TEXT_TYPES and isinstance(value, str) and not value.strip() + ) + + +@transaction.atomic +def update_solution( + *, + program_id, + solution_id, + actor, + expected_version, + expected_form_revision, + name=UNSET, + answers=UNSET, +): + solution = lock_solution(program_id, solution_id, actor) + require_captain(solution, actor) + require_mutable(solution) + require_version(solution, expected_version) + # Direct model/admin changes use the same program -> definition order. + list(solution.program.fields.select_for_update().order_by("pk")) + form = form_data(solution.program) + if form["revision"] != expected_form_revision: + raise SolutionError( + "form_revision_conflict", + "Форма решения изменилась. Обновите поля.", + meta={"current_form_revision": form["revision"]}, + ) + definitions = {field["id"]: field for field in form["fields"]} + changes = [] if answers is UNSET else answers + seen, problems = set(), [] + for answer in changes: + field_id, value = answer["field_id"], answer["value"] + if field_id in seen: + problems.append( + issue( + f"answers.{field_id}", + "duplicate_field", + "Поле повторяется в запросе.", + field_id, + ) + ) + continue + seen.add(field_id) + field = definitions.get(field_id) + if field is None: + problems.append( + issue( + f"answers.{field_id}", + "unknown_field", + "Поле не относится к этой программе.", + field_id, + ) + ) + continue + if not cleared_value(field, value): + problem = value_problem(field, value) + if problem: + problems.append(issue(f"answers.{field_id}", *problem, field_id)) + if problems: + raise SolutionError( + "invalid_answers", "Проверьте ответы решения.", status=400, errors=problems + ) + files = [ + (definitions[a["field_id"]], a["value"]) + for a in changes + if definitions[a["field_id"]]["type"] == "file" and a["value"] is not None + ] + # Два решения могут переиспользовать собственные файлы капитана: общий порядок URL. + list( + UserFile.objects.select_for_update() + .filter(pk__in=[value["url"] for _, value in files]) + .order_by("pk") + ) + owned = owned_file_urls(solution) + for field, value in files: + validate_file(solution, field, value, owned_urls=owned) + for answer in changes: + field = definitions[answer["field_id"]] + if cleared_value(field, answer["value"]): + solution.answers.filter(field_id=field["id"]).delete() + else: + ProgramSolutionAnswer.objects.update_or_create( + solution=solution, + field_id=field["id"], + defaults={"value": answer["value"]}, + ) + fields = [] + if name is not UNSET: + solution.name = name + fields.append("name") + return advance_version(solution, fields=fields) diff --git a/partner_programs/solution_api.py b/partner_programs/solution_api.py index e2a1adde..55ac4b55 100644 --- a/partner_programs/solution_api.py +++ b/partner_programs/solution_api.py @@ -1,7 +1,4 @@ -"""Основа контракта C00: контекст, создание и чтение. Команды BE02–BE06 закрыты.""" - -import hashlib -import json +"""C00: контекст, создание, чтение и команды состава решения.""" from django.db import transaction from django.db.models import Q @@ -12,33 +9,14 @@ from rest_framework.response import Response from rest_framework.views import APIView -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile -from partner_programs.permissions import can_manage_program +from partner_programs.models import PartnerProgramUserProfile from partner_programs.services.role_analytics import participant_project_links from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember - -MAX_ID = 2**53 - 1 -VALIDATION_DEFAULTS = dict( - min_length=None, - max_length=None, - minimum=None, - maximum=None, - allowed_extensions=[], - max_file_size_bytes=None, -) - - -class SolutionError(APIException): - def __init__(self, code, detail, status=409, *, errors=None, meta=None): - self.status_code = status - self.payload = dict( - code=code, detail=detail, errors=errors or [], meta=meta or {} - ) - super().__init__(detail=detail, code=code) - - -def issue(path, code, detail, field_id=None): - return dict(path=path, code=code, detail=detail, field_id=field_id) +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_schema import form_data +from partner_programs.solution_submission import readiness_issues +from partner_programs import solution_team +from partner_programs.solution_team import program_for, visible_solutions def iso(value): @@ -54,16 +32,6 @@ def user_data(user): ) -def program_for(pk, *, lock=False): - if not 0 < pk <= MAX_ID: - raise Http404 - query = PartnerProgram.objects.select_for_update() if lock else PartnerProgram.objects - try: - return query.get(pk=pk) - except PartnerProgram.DoesNotExist: - raise Http404 - - def current_application(program, user): solutions = list( ProgramSolution.objects.filter(program=program) @@ -97,47 +65,6 @@ def current_application(program, user): return None -def form_data(program): - fields = [ - dict( - id=f.pk, - type=f.field_type, - purpose="case" if f.name == "case" else f.purpose, - label=f.label, - help_text=f.help_text or "", - required=f.is_required, - position=f.position, - options=f.get_options_list(), - validation={ - key: f.validation.get(key, default) - for key, default in VALIDATION_DEFAULTS.items() - }, - ) - for f in program.fields.order_by("position", "pk") - ] - definition = dict(template=program.solution_template, fields=fields) - revision = hashlib.sha256( - json.dumps( - definition, ensure_ascii=False, sort_keys=True, separators=(",", ":") - ).encode() - ).hexdigest() - configured = program.solution_template in ("case_championship", "hackathon") and any( - f["purpose"] == "case" - and f["type"] == "select" - and f["required"] - and f["options"] - for f in fields - ) - return dict( - program_id=program.pk, - revision=revision, - template=program.solution_template, - is_configured=bool(configured), - is_snapshot=False, - fields=fields, - ) - - def context_data(program, user): application = current_application(program, user) registered = PartnerProgramUserProfile.objects.filter( @@ -150,6 +77,8 @@ def context_data(program, user): reason = "legacy_mode" elif not (registered or program.is_manager(user)): reason = "not_program_member" + elif not program.solution_creation_enabled: + reason = "solution_creation_disabled" elif not program.is_project_submission_open(): reason = "submission_closed" elif not form_data(program)["is_configured"]: @@ -167,12 +96,12 @@ def context_data(program, user): ) -def visible_solutions(program, user): - query = ProgramSolution.objects.filter(program=program) - # Экспертные назначения и публикация подключаются в BE07/BE08; до этого закрыты. - if can_manage_program(user, program): - return query - return query.filter(Q(captain=user) | Q(members__user=user)).distinct() +def participation_data(solution): + return dict( + status="archived" if solution.archived_at else "active", + archived_at=iso(solution.archived_at), + reason=solution.archive_reason, + ) def solution_data(solution, user): @@ -186,6 +115,8 @@ def solution_data(solution, user): ) own = solution.captain_id == user.pk or user.pk in accepted submitted = solution.status == "submitted" + archived = solution.archived_at is not None + frozen = submitted or archived form = form_data(program) answers = [ dict(field_id=a.field_id, value=a.value) @@ -202,8 +133,8 @@ def solution_data(solution, user): ) for m in members ] - snapshot = solution.submission_snapshot - if submitted and snapshot: + snapshot = solution.archive_snapshot if archived else solution.submission_snapshot + if frozen and snapshot: name, captain_id = snapshot["name"], snapshot["captain_id"] form, answers, member_data = ( snapshot["form"], @@ -212,33 +143,12 @@ def solution_data(solution, user): ) accepted = {member["user"]["id"] for member in member_data} problems = [] - if program.is_competitive and own: - if not name.strip(): - problems.append(issue("name", "required", "Укажите название решения.")) - if program.legacy_team_min_size and len(accepted) < program.legacy_team_min_size: - problems.append( - issue( - "team", "team_min_size", "В команде недостаточно принятых участников." - ) - ) - if program.legacy_team_max_size and len(accepted) > program.legacy_team_max_size: - problems.append( - issue("team", "team_max_size", "Превышен максимальный размер команды.") - ) - values = {a["field_id"]: a["value"] for a in answers} - for field in form["fields"]: - value = values.get(field["id"]) - if field["required"] and ( - value is None or isinstance(value, str) and not value.strip() - ): - problems.append( - issue( - f"answers.{field['id']}", - "required", - "Заполните обязательное поле.", - field["id"], - ) - ) + if archived: + problems = [ + issue("participation", "solution_archived", "Решение снято с участия.") + ] + elif program.is_competitive and own and not submitted: + problems = readiness_issues(solution, form, answers, accepted) return dict( id=solution.pk, program_id=program.pk, @@ -247,6 +157,7 @@ def solution_data(solution, user): captain_id=captain_id, created_at=iso(solution.created_at), updated_at=iso(solution.updated_at), + participation=participation_data(solution), team=dict( members=member_data, accepted_count=len(accepted), @@ -254,7 +165,7 @@ def solution_data(solution, user): reserved_count=len(accepted | pending), min_size=program.legacy_team_min_size, max_size=program.legacy_team_max_size, - is_frozen=submitted, + is_frozen=frozen, ), form=form, answers=answers, @@ -265,9 +176,35 @@ def solution_data(solution, user): deadline=iso(program.get_project_submission_deadline()), submitted_at=iso(solution.submitted_at), ), - # BE01 не публикует еще не реализованные команды BE02–BE06. permissions=dict( - can_edit=False, can_manage_team=False, can_invite=False, can_submit=False + can_edit=( + solution.captain_id == user.pk + and program.solution_storage_mode == "solution" + and not frozen + ), + can_manage_team=( + solution.captain_id == user.pk + and program.solution_storage_mode == "solution" + and not frozen + ), + can_invite=( + solution.captain_id == user.pk + and program.solution_storage_mode == "solution" + and not frozen + ), + can_submit=( + solution.captain_id == user.pk + and program.solution_storage_mode == "solution" + and not frozen + and program.is_competitive + and program.is_project_submission_open() + ), + can_leave=( + user.pk in accepted + and solution.captain_id != user.pk + and program.solution_storage_mode == "solution" + and not frozen + ), ), readiness=dict(is_ready=not problems, issues=problems) if program.is_competitive and own @@ -295,10 +232,40 @@ class CreateSolutionSerializer(StrictSerializer): name = NameField(required=False, min_length=1, max_length=256, trim_whitespace=False) +class CommandInteger(serializers.IntegerField): + def to_internal_value(self, value): + if isinstance(value, bool) or not isinstance(value, int): + self.fail("invalid") + return super().to_internal_value(value) + + +class TeamCommandSerializer(StrictSerializer): + expected_solution_version = CommandInteger(min_value=1) + + +class CaptainCommandSerializer(TeamCommandSerializer): + new_captain_user_id = CommandInteger(min_value=1, max_value=MAX_ID) + + +class TeamVersionQuery(StrictSerializer): + expected_solution_version = serializers.RegexField(r"^[1-9][0-9]*$") + + +def mutation_receipt(solution): + return dict( + solution_id=solution.pk, + solution_version=solution.version, + updated_at=iso(solution.updated_at), + ) + + class SolutionListQuery(StrictSerializer): limit = serializers.IntegerField(min_value=1, max_value=100, default=20) offset = serializers.IntegerField(min_value=0, default=0) search = serializers.CharField(required=False, allow_blank=True) + participation = serializers.ChoiceField( + choices=["active", "archived", "all"], default="active" + ) submission = serializers.ChoiceField( choices=["all", "submitted", "not_submitted"], default="all" ) @@ -332,6 +299,12 @@ def create_solution(program_id, user, data): "Создание заявки доступно участникам программы.", status=403, ) + if not program.solution_creation_enabled: + raise SolutionError( + "solution_creation_disabled", + "Создание новых решений временно приостановлено. " + "Ранее созданные заявки доступны.", + ) if not program.is_project_submission_open(): raise SolutionError("submission_closed", "Срок создания заявок завершен.") if not form_data(program)["is_configured"]: @@ -408,10 +381,14 @@ def get(self, request, pk): if not program.is_competitive and data["submission"] != "all": raise ValidationError({"submission": "Фильтр сдачи неприменим."}) rows = ( - visible_solutions(program, request.user) + visible_solutions( + program, request.user, include_archived=data["participation"] != "active" + ) .select_related("captain") .order_by("created_at", "pk") ) + if data["participation"] == "archived": + rows = rows.filter(archived_at__isnull=False) if data.get("search"): rows = rows.filter(name__icontains=data["search"]) if data["submission"] != "all": @@ -427,8 +404,10 @@ def get(self, request, pk): name, captain = row.name, user_data(row.captain) accepted_count = row.members.count() case_value = case.value if case else None - if row.status == "submitted" and row.submission_snapshot: - snapshot = row.submission_snapshot + snapshot = ( + row.archive_snapshot if row.archived_at else row.submission_snapshot + ) + if snapshot and (row.status == "submitted" or row.archived_at): name = snapshot["name"] accepted_count = len(snapshot["members"]) captain = next(m["user"] for m in snapshot["members"] if m["is_captain"]) @@ -453,6 +432,7 @@ def get(self, request, pk): case_value=case_value, status=row.status, submitted_at=iso(row.submitted_at), + participation=participation_data(row), ) ) @@ -478,16 +458,68 @@ def post(self, request, pk): class SolutionDetailView(SolutionAPIView): + def patch(self, request, pk, solution_id): + from partner_programs.solution_answer_api import patch_solution + + return patch_solution(request, pk, solution_id) + def get(self, request, pk, solution_id): program = program_for(pk) if not 0 < solution_id <= MAX_ID: raise Http404 try: solution = ( - visible_solutions(program, request.user) + visible_solutions(program, request.user, include_archived=True) .select_related("program") .get(pk=solution_id) ) except ProgramSolution.DoesNotExist: raise Http404 return Response(solution_data(solution, request.user)) + + +class SolutionMemberView(SolutionAPIView): + def delete(self, request, pk, solution_id, user_id): + query = TeamVersionQuery(data=request.query_params) + query.is_valid(raise_exception=True) + if request.data: + raise ValidationError({"body": "Передайте версию в параметрах запроса."}) + solution = solution_team.remove_member( + program_id=pk, + solution_id=solution_id, + user_id=user_id, + actor=request.user, + expected_version=query.validated_data["expected_solution_version"], + ) + return Response(mutation_receipt(solution)) + + +class SolutionLeaveView(SolutionAPIView): + def post(self, request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = TeamCommandSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + solution_team.leave_team( + program_id=pk, + solution_id=solution_id, + actor=request.user, + expected_version=serializer.validated_data["expected_solution_version"], + ) + return Response(status=204) + + +class SolutionCaptainView(SolutionAPIView): + def post(self, request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = CaptainCommandSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + solution = solution_team.transfer_captain( + program_id=pk, + solution_id=solution_id, + user_id=serializer.validated_data["new_captain_user_id"], + actor=request.user, + expected_version=serializer.validated_data["expected_solution_version"], + ) + return Response(mutation_receipt(solution)) diff --git a/partner_programs/solution_archive_action_api.py b/partner_programs/solution_archive_action_api.py new file mode 100644 index 00000000..0962bf88 --- /dev/null +++ b/partner_programs/solution_archive_action_api.py @@ -0,0 +1,55 @@ +"""Organizer withdrawal uses a separate confirmed endpoint and retains the work.""" + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.services import solution_archive_actions +from partner_programs.solution_api import ( + CommandInteger, + SolutionAPIView, + StrictSerializer, +) +from partner_programs.solution_case_action_api import ExactPreviewToken +from partner_programs.solution_errors import MAX_ID + + +class ArchivePreviewSerializer(StrictSerializer): + ids = serializers.ListField( + child=CommandInteger(min_value=1, max_value=MAX_ID), + allow_empty=False, + max_length=10000, + ) + + def validate_ids(self, ids): + if len(ids) != len(set(ids)): + raise serializers.ValidationError("Выберите решения без повторов.") + return sorted(ids) + + +class ArchiveApplySerializer(ArchivePreviewSerializer): + preview_token = ExactPreviewToken( + max_length=200000, trim_whitespace=False, allow_blank=False + ) + + +class SolutionArchiveView(SolutionAPIView): + apply_action = False + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def post(self, request, pk): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = ( + ArchiveApplySerializer if self.apply_action else ArchivePreviewSerializer + )(data=request.data) + serializer.is_valid(raise_exception=True) + command = ( + solution_archive_actions.apply + if self.apply_action + else solution_archive_actions.preview + ) + return Response(command(pk, request.user, serializer.validated_data)) diff --git a/partner_programs/solution_attention_api.py b/partner_programs/solution_attention_api.py new file mode 100644 index 00000000..cf6e8f3d --- /dev/null +++ b/partner_programs/solution_attention_api.py @@ -0,0 +1,73 @@ +"""Закрытые команды напоминаний экспертам о реальных решениях.""" + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.serializers.attention_actions import ( + MAX_SELECTION, + AttentionActionSerializer, + AttentionConfirmationSerializer, + AttentionSelectionSerializer, +) +from partner_programs.services.attention_actions import ( + confirm_reminder, + preview_reminder, +) +from partner_programs.services.solution_attention import SOLUTION_AWAITING +from partner_programs.solution_analytics_api import SolutionAnalyticsAPIView +from partner_programs.solution_api import StrictSerializer +from partner_programs.solution_errors import MAX_ID, SolutionError + + +class SolutionReminderSelection(StrictSerializer, AttentionSelectionSerializer): + ids = serializers.ListField( + child=serializers.IntegerField(min_value=1, max_value=MAX_ID), + max_length=MAX_SELECTION, + default=list, + ) + excluded_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1, max_value=MAX_ID), + max_length=MAX_SELECTION, + default=list, + ) + + +class SolutionReminderPreviewSerializer(StrictSerializer, AttentionActionSerializer): + selection = SolutionReminderSelection() + + +class SolutionReminderConfirmSerializer( + StrictSerializer, AttentionConfirmationSerializer +): + pass + + +class SolutionExpertReminderView(SolutionAnalyticsAPIView): + reminder_action = "preview" + + def post(self, request, pk): + program = self.managed_program(request, pk) + if not (program.is_competitive and program.is_distributed_evaluation): + raise SolutionError( + "reminders_not_applicable", + "Напоминания о назначениях доступны при распределенном оценивании.", + ) + self.query(request) + serializer = ( + SolutionReminderConfirmSerializer + if self.reminder_action == "confirm" + else SolutionReminderPreviewSerializer + )(data=request.data) + serializer.is_valid(raise_exception=True) + if self.reminder_action == "confirm": + result = confirm_reminder( + program, + SOLUTION_AWAITING, + request.user, + serializer.validated_data["token"], + ) + else: + result = preview_reminder( + program, SOLUTION_AWAITING, request.user, serializer.validated_data + ) + return Response(result) diff --git a/partner_programs/solution_case_action_api.py b/partner_programs/solution_case_action_api.py new file mode 100644 index 00000000..2ee5c012 --- /dev/null +++ b/partner_programs/solution_case_action_api.py @@ -0,0 +1,56 @@ +"""C00 organizer case preview/confirmation, separate from submit actions.""" + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.services import solution_case_actions +from partner_programs.solution_api import SolutionAPIView, StrictSerializer + + +class ExactCaseName(serializers.RegexField): + def to_internal_value(self, data): + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +class SolutionCaseChangePreviewSerializer(StrictSerializer): + case_name = ExactCaseName(r"\S", min_length=1, max_length=1000, trim_whitespace=False) + + +class ExactPreviewToken(serializers.CharField): + def to_internal_value(self, data): + if not isinstance(data, str): + self.fail("invalid") + return super().to_internal_value(data) + + +class SolutionCaseChangeApplySerializer(SolutionCaseChangePreviewSerializer): + preview_token = ExactPreviewToken( + max_length=200000, trim_whitespace=False, allow_blank=False + ) + + +class SolutionCaseChangeView(SolutionAPIView): + apply_action = False + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def post(self, request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = ( + SolutionCaseChangeApplySerializer + if self.apply_action + else SolutionCaseChangePreviewSerializer + )(data=request.data) + serializer.is_valid(raise_exception=True) + command = ( + solution_case_actions.apply + if self.apply_action + else solution_case_actions.preview + ) + return Response(command(pk, solution_id, request.user, serializer.validated_data)) diff --git a/partner_programs/solution_errors.py b/partner_programs/solution_errors.py new file mode 100644 index 00000000..615fe9a3 --- /dev/null +++ b/partner_programs/solution_errors.py @@ -0,0 +1,18 @@ +"""Ошибки C00 для HTTP и транзакционных сервисов решений.""" + +from rest_framework.exceptions import APIException + +MAX_ID = 2**53 - 1 + + +class SolutionError(APIException): + def __init__(self, code, detail, status=409, *, errors=None, meta=None): + self.status_code = status + self.payload = dict( + code=code, detail=detail, errors=errors or [], meta=meta or {} + ) + super().__init__(detail=detail, code=code) + + +def issue(path, code, detail, field_id=None): + return dict(path=path, code=code, detail=detail, field_id=field_id) diff --git a/partner_programs/solution_file_signals.py b/partner_programs/solution_file_signals.py new file mode 100644 index 00000000..c9d5ca48 --- /dev/null +++ b/partner_programs/solution_file_signals.py @@ -0,0 +1,14 @@ +from django.db.models.deletion import ProtectedError +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from files.models import UserFile +from partner_programs.solution_files import used_by_submitted_solution + + +@receiver(pre_delete, sender=UserFile, dispatch_uid="protect_solution_snapshot_file") +def protect_snapshot_file(sender, instance, using, **kwargs): + # Collector вызывает pre_delete в atomic; блокировка общая с PATCH/submit. + sender.objects.using(using).select_for_update().filter(pk=instance.pk).first() + if used_by_submitted_solution(instance.pk): + raise ProtectedError("Файл используется в сохраненном решении.", [instance]) diff --git a/partner_programs/solution_files.py b/partner_programs/solution_files.py new file mode 100644 index 00000000..fcfdd8b9 --- /dev/null +++ b/partner_programs/solution_files.py @@ -0,0 +1,176 @@ +"""Привязка зарегистрированных файлов и защита снимков сданных решений.""" + +import posixpath +import tempfile +from urllib.parse import unquote, urlsplit + +import requests +from django.conf import settings +from django.db.models import Q + +from files.content import detect_content_type +from files.models import UserFile +from partner_programs.solution_errors import SolutionError, issue +from partner_programs.solution_models import ProgramSolution, ProgramSolutionAnswer +from partner_programs.solution_schema import MAX_FILE_SIZE + +MIME_BY_EXTENSION = { + "pdf": {"application/pdf"}, + "zip": { + "application/zip", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + "docx": {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + "pptx": {"application/vnd.openxmlformats-officedocument.presentationml.presentation"}, + "xlsx": {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + "doc": {"application/x-ole-storage"}, + "xls": {"application/x-ole-storage"}, + "ppt": {"application/x-ole-storage"}, + "png": {"image/png"}, + "jpg": {"image/jpeg"}, + "jpeg": {"image/jpeg"}, + "webp": {"image/webp"}, + "gif": {"image/gif"}, + "tif": {"image/tiff"}, + "tiff": {"image/tiff"}, + "txt": {"text/plain"}, + "csv": {"text/plain"}, + "md": {"text/plain"}, + "json": {"text/plain"}, + "svg": {"text/plain"}, + "html": {"text/plain"}, + "css": {"text/plain"}, + "mp4": {"video/mp4"}, + "mp3": {"audio/mpeg"}, + "ogg": {"application/ogg"}, + "wav": {"audio/wav"}, +} + + +def file_error(field, code, detail): + raise SolutionError( + "invalid_answers", + "Проверьте загруженный файл.", + status=400, + errors=[issue(f"answers.{field['id']}", code, detail, field["id"])], + ) + + +def owned_file_urls(solution): + values = solution.answers.filter(field__field_type="file").values_list( + "value", flat=True + ) + return { + value["url"] + for value in values + if isinstance(value, dict) and isinstance(value.get("url"), str) + } + + +def trusted_cdn_url(link): + try: + base, target = urlsplit(settings.SELECTEL_SWIFT_URL), urlsplit(link) + prefix = posixpath.normpath(unquote(base.path)).rstrip("/") + "/" + path = posixpath.normpath(unquote(target.path)) + return ( + target.scheme in ("http", "https") + and target.scheme == base.scheme + and target.netloc.lower() == base.netloc.lower() + and not target.username + and not target.password + and path.startswith(prefix) + ) + except ValueError: + return False + + +def verify_legacy_upload(upload, maximum, field): + """Проверяет прежнюю загрузку только в своем CDN, никогда по произвольному URL.""" + if upload.detected_mime_type: + return + if not trusted_cdn_url(upload.link): + file_error(field, "file_unverified", "Загрузите файл через платформу повторно.") + response = None + try: + response = requests.get( + upload.link, stream=True, timeout=(5, 10), allow_redirects=False + ) + if response.status_code != 200: + file_error( + field, "file_unavailable", "Файл недоступен. Загрузите его повторно." + ) + with tempfile.SpooledTemporaryFile(max_size=1048576) as stream: + size = 0 + for chunk in response.iter_content(chunk_size=65536): + size += len(chunk) + if size > maximum: + file_error( + field, "file_too_large", "Размер файла превышает допустимый." + ) + stream.write(chunk) + stream.seek(0) + upload.detected_mime_type = detect_content_type(stream) + upload.size = size + upload.save(update_fields=["detected_mime_type", "size"]) + except requests.RequestException as error: + try: + file_error( + field, "file_unavailable", "Не удалось проверить файл. Повторите попытку." + ) + except SolutionError as controlled: + raise controlled from error + finally: + if response is not None: + response.close() + + +def validate_file(solution, field, value, *, owned_urls=None): + try: + upload = UserFile.objects.select_for_update().get(pk=value["url"]) + except UserFile.DoesNotExist: + file_error(field, "file_not_found", "Файл не найден. Загрузите его повторно.") + if upload.user_id != solution.captain_id and upload.link not in ( + owned_urls if owned_urls is not None else owned_file_urls(solution) + ): + file_error(field, "file_forbidden", "Этот файл недоступен для заявки.") + rules = field["validation"] + maximum = min(MAX_FILE_SIZE, rules["max_file_size_bytes"] or MAX_FILE_SIZE) + if upload.size > maximum: + file_error(field, "file_too_large", "Размер файла превышает допустимый.") + extension = upload.extension.lower() + if rules["allowed_extensions"] and extension not in rules["allowed_extensions"]: + file_error(field, "file_type_not_allowed", "Выберите разрешенный тип файла.") + verify_legacy_upload(upload, maximum, field) + expected = MIME_BY_EXTENSION.get(extension) + if expected and upload.detected_mime_type not in expected: + file_error( + field, "file_type_mismatch", "Содержимое файла не соответствует его типу." + ) + return upload + + +def used_by_submitted_solution(link): + if ProgramSolutionAnswer.objects.filter( + Q(solution__status="submitted") | Q(solution__archived_at__isnull=False), + value__url=link, + ).exists(): + return True + # Снимок остается источником истины и после переноса исторических записей. + for submitted, archived in ProgramSolution.all_objects.filter( + Q(status="submitted") | Q(archived_at__isnull=False) + ).values_list("submission_snapshot", "archive_snapshot"): + for snapshot in (submitted, archived): + if not isinstance(snapshot, dict): + continue + for answer in snapshot.get("answers", []): + value = answer.get("value") if isinstance(answer, dict) else None + if isinstance(value, dict) and value.get("url") == link: + return True + return False + + +def protect_solution_file(upload): + if used_by_submitted_solution(upload.link): + raise SolutionError("file_in_use", "Файл используется в сохраненном решении.") diff --git a/partner_programs/solution_form_api.py b/partner_programs/solution_form_api.py new file mode 100644 index 00000000..d07f0dab --- /dev/null +++ b/partner_programs/solution_form_api.py @@ -0,0 +1,115 @@ +"""C00 API актуальной формы, безопасного конструктора и начальных шаблонов.""" + +import math + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs import solution_forms +from partner_programs.solution_api import ( + CommandInteger, + NameField, + SolutionAPIView, + StrictSerializer, +) +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_invitation_api import no_query +from partner_programs.solution_schema import ( + FIELD_TYPES, + MAX_FILE_SIZE, + PURPOSES, + TEMPLATES, + form_data, + template_fields, +) +from partner_programs.solution_team import program_for + + +class CommandBoolean(serializers.BooleanField): + def to_internal_value(self, value): + if not isinstance(value, bool): + self.fail("invalid") + return value + + +class CommandNumber(serializers.Field): + def to_internal_value(self, value): + try: + valid = ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(value) + ) + except OverflowError: + valid = False + if not valid: + raise serializers.ValidationError("Ожидается конечное число.") + return value + + def to_representation(self, value): + return value + + +class ValidationSerializer(StrictSerializer): + min_length = CommandInteger(min_value=0, allow_null=True) + max_length = CommandInteger(min_value=0, allow_null=True) + minimum = CommandNumber(allow_null=True) + maximum = CommandNumber(allow_null=True) + allowed_extensions = serializers.ListField( + child=serializers.RegexField(r"^[a-z0-9]+$", trim_whitespace=False) + ) + max_file_size_bytes = CommandInteger( + min_value=1, max_value=MAX_FILE_SIZE, allow_null=True + ) + + +class FieldWriteSerializer(StrictSerializer): + id = CommandInteger(min_value=1, max_value=MAX_ID, allow_null=True) + type = serializers.ChoiceField(choices=FIELD_TYPES) + purpose = serializers.ChoiceField(choices=PURPOSES) + label = NameField(min_length=1, max_length=256, trim_whitespace=False) + help_text = NameField(max_length=4000, allow_blank=True, trim_whitespace=False) + required = CommandBoolean() + position = CommandInteger(min_value=0) + options = serializers.ListField( + child=NameField(min_length=1, max_length=1000, trim_whitespace=False) + ) + validation = ValidationSerializer() + + +class FormWriteSerializer(StrictSerializer): + expected_revision = serializers.RegexField(r"^[a-f0-9]{64}$", trim_whitespace=False) + template = serializers.ChoiceField(choices=TEMPLATES) + fields = FieldWriteSerializer(many=True) + + +class SolutionFormView(SolutionAPIView): + def get(self, request, pk): + no_query(request) + program = program_for(pk) + solution_forms.require_reader(program, request.user) + return Response(form_data(program)) + + +class ManagedSolutionFormView(SolutionAPIView): + def get(self, request, pk): + no_query(request) + program = program_for(pk) + solution_forms.require_manager(program, request.user) + return Response(solution_forms.management_data(program)) + + def put(self, request, pk): + no_query(request) + serializer = FormWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + return Response( + solution_forms.save_form( + program_id=pk, actor=request.user, **serializer.validated_data + ) + ) + + +class SolutionFormTemplatesView(SolutionAPIView): + def get(self, request): + no_query(request) + return Response(template_fields()) diff --git a/partner_programs/solution_forms.py b/partner_programs/solution_forms.py new file mode 100644 index 00000000..2aecb9d6 --- /dev/null +++ b/partner_programs/solution_forms.py @@ -0,0 +1,253 @@ +"""Атомарное управление формой программы; существующие ответы остаются сохранными.""" + +import json +from uuid import uuid4 + +from django.db import transaction + +from files.models import UserFile +from partner_programs.models import PartnerProgramField, PartnerProgramUserProfile +from partner_programs.permissions import can_manage_program +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_schema import ( + definition_error, + empty_value, + field_data, + form_data, + validate_definition, + value_problem, +) +from partner_programs.solution_team import program_for + + +def require_manager(program, actor): + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", + "Настройка формы доступна организатору программы.", + status=403, + ) + + +def require_reader(program, actor): + if ( + not can_manage_program(actor, program) + and not PartnerProgramUserProfile.objects.filter( + partner_program=program, user=actor + ).exists() + ): + raise SolutionError( + "permission_denied", "Форма доступна участникам этой программы.", status=403 + ) + + +def stored_values(field): + values = list(field.solution_answers.values_list("value", flat=True)) + for value in field.values.values_list("value_text", flat=True): + if field.field_type in ("number", "checkbox", "file"): + try: + value = json.loads(value) + except (TypeError, ValueError): + if field.field_type == "file": + value = {"url": value} + values.append(value) + return [value for value in values if not empty_value(value)] + + +def answer_count(field): + return len(stored_values(field)) + + +def has_answers(program): + return any(answer_count(field) for field in program.fields.all()) + + +def restrictions_narrowed(before, after): + for key in ("min_length", "minimum"): + if after[key] is not None and (before[key] is None or after[key] > before[key]): + return True + for key in ("max_length", "maximum", "max_file_size_bytes"): + if after[key] is not None and (before[key] is None or after[key] < before[key]): + return True + old_extensions, new_extensions = set(before["allowed_extensions"]), set( + after["allowed_extensions"] + ) + return bool( + new_extensions and (not old_extensions or old_extensions - new_extensions) + ) + + +def check_evolution(field, candidate): + values = stored_values(field) + if not values: + return + previous = field_data(field) + path = f"fields.{field.pk}" + if candidate is None: + definition_error( + path, + "Нельзя удалить поле с ответами.", + field.pk, + code="field_in_use", + status=409, + ) + if ( + candidate["type"] != previous["type"] + or candidate["purpose"] != previous["purpose"] + ): + definition_error( + path, + "Нельзя менять тип или назначение поля с ответами.", + field.pk, + code="field_in_use", + status=409, + ) + if candidate["type"] in ("select", "radio"): + if any(value not in candidate["options"] for value in values): + definition_error( + path + ".options", + "Нельзя удалить или переименовать используемый вариант.", + field.pk, + code="option_in_use", + status=409, + ) + if not restrictions_narrowed(previous["validation"], candidate["validation"]): + return + for value in values: + problem = value_problem(candidate, value) + if candidate["type"] == "file" and not problem: + upload = UserFile.objects.filter(pk=value["url"]).first() + rules = candidate["validation"] + if upload is None: + problem = ("file_not_found", "Данные существующего файла недоступны.") + elif ( + rules["max_file_size_bytes"] + and upload.size > rules["max_file_size_bytes"] + ): + problem = ("file_too_large", "Существующий файл превышает новый предел.") + elif ( + rules["allowed_extensions"] + and upload.extension.lower() not in rules["allowed_extensions"] + ): + problem = ( + "file_type_not_allowed", + "Существующий тип файла исключен новым правилом.", + ) + if problem: + definition_error( + path + ".validation", + "Новому ограничению не соответствуют сохраненные ответы.", + field.pk, + code="constraint_in_use", + status=409, + ) + + +def management_data(program): + form = form_data(program) + fields = list(program.fields.all()) + usage = [ + dict(field_id=field.pk, answers_count=answer_count(field)) for field in fields + ] + for row in usage: + row.update( + can_delete=row["answers_count"] == 0, + can_change_type=row["answers_count"] == 0, + ) + required = {field["id"] for field in form["fields"] if field["required"]} + affected = 0 + for solution in program.solutions.filter(status="draft").prefetch_related("answers"): + present = { + row.field_id for row in solution.answers.all() if not empty_value(row.value) + } + if required - present: + affected += 1 + return dict( + form=form, + has_solutions=program.solutions.exists(), + has_answers=any(row["answers_count"] for row in usage), + affected_draft_count=affected, + fields_usage=sorted(usage, key=lambda row: row["field_id"]), + ) + + +@transaction.atomic +def save_form(*, program_id, actor, expected_revision, template, fields): + program = program_for(program_id, lock=True) + require_manager(program, actor) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует проектную форму." + ) + if form_data(program)["revision"] != expected_revision: + raise SolutionError( + "form_revision_conflict", + "Форма изменена другим организатором.", + meta={"current_form_revision": form_data(program)["revision"]}, + ) + existing = {row.pk: row for row in program.fields.select_for_update().order_by("pk")} + used = any(answer_count(row) for row in existing.values()) + if program.solution_template != template and used: + raise SolutionError( + "template_locked", "После появления ответов шаблон нельзя менять." + ) + ids = [row["id"] for row in fields if row["id"] is not None] + if len(ids) != len(set(ids)) or set(ids) - set(existing): + definition_error("fields", "Переданы повторные или чужие ID полей.") + positions = [row["position"] for row in fields] + if len(positions) != len(set(positions)): + definition_error("fields", "Порядок полей не должен повторяться.") + purposes = [row["purpose"] for row in fields if row["purpose"] != "other"] + if len(purposes) != len(set(purposes)) or purposes.count("case") != 1: + definition_error( + "fields", + "Нужен один выбор кейса или задачи; назначения не должны повторяться.", + ) + candidates = {row["id"]: row for row in fields if row["id"] is not None} + for row in fields: + validate_definition(row) + if row["type"] == "radio" and ( + row["id"] is None or existing[row["id"]].field_type != "radio" + ): + definition_error( + "fields", "Новые поля radio не создаются. Используйте select.", row["id"] + ) + for field_id, previous in existing.items(): + check_evolution(previous, candidates.get(field_id)) + removed = set(existing) - set(ids) + # Пустые служебные записи не являются ответами и не мешают удалению определения. + for field_id in removed: + existing[field_id].solution_answers.all().delete() + program.fields.filter(pk__in=removed).delete() + # Освободить системное имя до изменения назначения незаполненного case. + for field_id, candidate in candidates.items(): + if existing[field_id].name == "case" and candidate["purpose"] != "case": + program.fields.filter(pk=field_id).update(name="solution_" + uuid4().hex) + additions = [] + for position, candidate in enumerate(sorted(fields, key=lambda row: row["position"])): + previous = existing.get(candidate["id"]) + values = dict( + field_type=candidate["type"], + purpose=candidate["purpose"], + label=candidate["label"], + help_text=candidate["help_text"], + is_required=candidate["required"], + position=position, + validation=candidate["validation"], + solution_options=candidate["options"], + options="|".join(candidate["options"]), + name="case" + if candidate["purpose"] == "case" + else previous.name + if previous and previous.name != "case" + else "solution_" + uuid4().hex, + show_filter=candidate["purpose"] == "case", + ) + if previous: + program.fields.filter(pk=previous.pk).update(**values) + else: + additions.append(PartnerProgramField(partner_program=program, **values)) + PartnerProgramField.objects.bulk_create(additions) + type(program).objects.filter(pk=program.pk).update(solution_template=template) + program.solution_template = template + return management_data(program) diff --git a/partner_programs/solution_history_api.py b/partner_programs/solution_history_api.py new file mode 100644 index 00000000..b3229ba8 --- /dev/null +++ b/partner_programs/solution_history_api.py @@ -0,0 +1,132 @@ +"""Program-scoped, read-only access to confirmed administrative revisions.""" + +from django.http import Http404 +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.permissions import can_manage_program +from partner_programs.solution_api import ( + SolutionAPIView, + StrictSerializer, + iso, + user_data, +) +from partner_programs.solution_errors import MAX_ID, SolutionError +from partner_programs.solution_models import ProgramSolution, SolutionAdministrativeChange +from partner_programs.solution_team import program_for + + +def managed_solution(program_id, solution_id, actor): + program = program_for(program_id) + if not can_manage_program(actor, program): + raise SolutionError( + "permission_denied", + "История доступна организатору программы.", + status=403, + ) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + if not 0 < solution_id <= MAX_ID: + raise Http404 + try: + return ProgramSolution.all_objects.get(program=program, pk=solution_id) + except ProgramSolution.DoesNotExist: + raise Http404 + + +def change_data(change): + data = dict( + id=change.pk, + application=dict( + kind="solution", + program_id=change.solution.program_id, + solution_id=change.solution_id, + name=change.solution.name, + ), + action=change.action, + actor=user_data(change.actor) if change.actor else None, + actor_name=change.actor_name, + created_at=iso(change.created_at), + before_version=change.before_version, + after_version=change.after_version, + removed_assignments_count=len(change.removed_assignment_ids), + score_count=change.score_count, + was_submitted=change.was_submitted, + ) + if change.action == "archive": + data.update( + archived_at=iso(change.archived_at), + reason=change.archive_reason, + revoked_invitation_count=len(change.revoked_invitation_ids), + ) + if change.registration_removal_id: + data["registration_removal_id"] = change.registration_removal_id + elif change.action == "remove_registrations": + data.update( + removed_members=change.removed_members, + registration_removal_id=change.registration_removal_id, + revoked_invitation_count=len(change.revoked_invitation_ids), + ) + elif change.action == "remove_member": + data.update( + removed_member=change.removed_member, + revoked_invitation_count=len(change.revoked_invitation_ids), + ) + else: + data.update( + case_field_id=change.case_field_id, + previous_case=change.previous_case, + case_name=change.case_name, + ) + return data + + +class HistoryQuery(StrictSerializer): + offset = serializers.IntegerField(min_value=0, default=0) + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + + +class SolutionHistoryView(SolutionAPIView): + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def get(self, request, pk, solution_id, change_id=None): + solution = managed_solution(pk, solution_id, request.user) + query = (StrictSerializer if change_id is not None else HistoryQuery)( + data=request.query_params + ) + query.is_valid(raise_exception=True) + rows = SolutionAdministrativeChange.objects.filter( + solution=solution + ).select_related("actor", "solution") + if change_id is not None: + if not 0 < change_id <= MAX_ID: + raise Http404 + try: + change = rows.get(pk=change_id) + except SolutionAdministrativeChange.DoesNotExist: + raise Http404 + data = dict( + change=change_data(change), + before_snapshot=change.before_snapshot, + after_snapshot=change.after_snapshot, + ) + if change.action == "archive": + data["archive_snapshot"] = change.archive_snapshot + return Response(data) + offset, limit = query.validated_data["offset"], query.validated_data["limit"] + return Response( + dict( + count=rows.count(), + offset=offset, + limit=limit, + results=[ + change_data(row) + for row in rows[offset : offset + limit] # noqa: E203 + ], + ) + ) diff --git a/partner_programs/solution_invitation_api.py b/partner_programs/solution_invitation_api.py new file mode 100644 index 00000000..6c279ddd --- /dev/null +++ b/partner_programs/solution_invitation_api.py @@ -0,0 +1,275 @@ +"""HTTP-контракт C00 для принадлежащих решению приглашений.""" + +from django.http import Http404 +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from partner_programs import solution_invitations, solution_team +from partner_programs.solution_api import ( + CommandInteger, + NameField, + SolutionAPIView, + StrictSerializer, + iso, + user_data, +) +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_models import ProgramSolution + + +class InvitationFields(StrictSerializer): + role = NameField( + required=False, + allow_null=False, + allow_blank=False, + min_length=1, + max_length=128, + trim_whitespace=False, + ) + + specialization = NameField( + required=False, + allow_null=True, + allow_blank=True, + max_length=100, + trim_whitespace=False, + ) + motivational_letter = NameField( + required=False, + allow_null=True, + allow_blank=True, + max_length=4096, + trim_whitespace=False, + ) + + def validate_role(self, value): + solution_invitations.require_role(value) + return value + + +class CreateInvitationSerializer(InvitationFields): + role = NameField( + required=True, + allow_null=False, + allow_blank=False, + min_length=1, + max_length=128, + trim_whitespace=False, + ) + expected_solution_version = CommandInteger(min_value=1) + recipient_user_id = CommandInteger(min_value=1, max_value=MAX_ID) + + +class UpdateInvitationSerializer(InvitationFields): + expected_version = CommandInteger(min_value=1) + expected_solution_version = CommandInteger(min_value=1) + + def validate(self, data): + if not set(data) & {"role", "specialization", "motivational_letter"}: + raise ValidationError({"fields": "Укажите изменяемое поле приглашения."}) + return data + + +class InvitationActionSerializer(StrictSerializer): + expected_version = CommandInteger(min_value=1) + + +class InvitationVersionQuery(StrictSerializer): + expected_version = serializers.RegexField(r"^[1-9][0-9]*$") + expected_solution_version = serializers.RegexField(r"^[1-9][0-9]*$") + + +class InvitationListQuery(StrictSerializer): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + status = serializers.ChoiceField( + choices=["pending", "accepted", "declined"], default="pending" + ) + + +def invitation_data(invitation, actor): + solution = invitation.solution + enabled = ( + solution.program.solution_storage_mode == "solution" + and solution.archived_at is None + ) + pending = invitation.status == "pending" and enabled + mutable = pending and solution.status != "submitted" + captain, recipient = ( + solution.captain_id == actor.pk, + invitation.recipient_id == actor.pk, + ) + name = ( + (solution.submission_snapshot or {}).get("name", solution.name) + if solution.status == "submitted" + else solution.name + ) + return dict( + id=invitation.pk, + version=invitation.version, + program=dict(id=solution.program_id, name=solution.program.name), + solution=dict( + kind="solution", + program_id=solution.program_id, + solution_id=solution.pk, + name=name, + ), + captain=user_data(solution.captain), + recipient=user_data(invitation.recipient), + role=invitation.role, + specialization=invitation.specialization, + motivational_letter=invitation.motivational_letter, + status=invitation.status, + created_at=iso(invitation.created_at), + updated_at=iso(invitation.updated_at), + permissions=dict( + can_edit=captain and mutable, + can_revoke=captain and pending, + can_accept=recipient and mutable, + can_decline=recipient and pending, + ), + ) + + +def invitation_page(query, request): + serializer = InvitationListQuery(data=request.query_params) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + rows = query.filter(status=data["status"]).order_by("created_at", "pk") + offset, limit, total = data["offset"], data["limit"], rows.count() + stop = offset + limit + + def link(start): + params = request.query_params.copy() + params["offset"], params["limit"] = start, limit + return request.build_absolute_uri("?" + params.urlencode()) + + return dict( + count=total, + next=link(offset + limit) if offset + limit < total else None, + previous=link(max(0, offset - limit)) if offset > 0 else None, + results=[invitation_data(row, request.user) for row in rows[offset:stop]], + ) + + +def no_query(request): + serializer = StrictSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + +def read_invitation(invitation_id, actor): + if not 0 < invitation_id <= MAX_ID: + raise Http404 + try: + return solution_invitations.visible_invitations(actor).get(pk=invitation_id) + except solution_invitations.SolutionInvitation.DoesNotExist: + raise Http404 + + +class SolutionInvitationsView(SolutionAPIView): + def get(self, request, pk, solution_id): + program = solution_team.program_for(pk) + if not 0 < solution_id <= MAX_ID: + raise Http404 + try: + solution = solution_team.visible_solutions(program, request.user).get( + pk=solution_id + ) + except ProgramSolution.DoesNotExist: + raise Http404 + solution_team.require_captain(solution, request.user) + return Response( + invitation_page( + solution_invitations.visible_invitations(request.user).filter( + solution=solution + ), + request, + ) + ) + + def post(self, request, pk, solution_id): + no_query(request) + serializer = CreateInvitationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + invitation = solution_invitations.create_invitation( + program_id=pk, + solution_id=solution_id, + actor=request.user, + **serializer.validated_data + ) + return Response( + dict( + invitation=invitation_data(invitation, request.user), + solution_version=invitation.solution.version, + ), + status=201, + ) + + +class MySolutionInvitationsView(SolutionAPIView): + def get(self, request): + return Response( + invitation_page( + solution_invitations.visible_invitations(request.user).filter( + recipient=request.user + ), + request, + ) + ) + + +class SolutionInvitationDetailView(SolutionAPIView): + def get(self, request, invitation_id): + no_query(request) + return Response( + invitation_data(read_invitation(invitation_id, request.user), request.user) + ) + + def patch(self, request, invitation_id): + no_query(request) + serializer = UpdateInvitationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + invitation = solution_invitations.update_invitation( + invitation_id=invitation_id, actor=request.user, **serializer.validated_data + ) + return Response( + dict( + invitation=invitation_data(invitation, request.user), + solution_version=invitation.solution.version, + ) + ) + + def delete(self, request, invitation_id): + serializer = InvitationVersionQuery(data=request.query_params) + serializer.is_valid(raise_exception=True) + if request.data: + raise ValidationError({"body": "Передайте версии в параметрах запроса."}) + solution_invitations.revoke_invitation( + invitation_id=invitation_id, actor=request.user, **serializer.validated_data + ) + return Response(status=204) + + +class SolutionInvitationActionView(SolutionAPIView): + decision = None + + def post(self, request, invitation_id): + no_query(request) + serializer = InvitationActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + accepted = self.decision == "accept" + invitation = solution_invitations.decide_invitation( + invitation_id=invitation_id, + actor=request.user, + accept=accepted, + **serializer.validated_data + ) + data = invitation_data(invitation, request.user) + return Response( + dict( + invitation=data, + application=data["solution"] if accepted else None, + solution_version=invitation.solution.version if accepted else None, + ) + ) diff --git a/partner_programs/solution_invitation_urls.py b/partner_programs/solution_invitation_urls.py new file mode 100644 index 00000000..4a1c5faf --- /dev/null +++ b/partner_programs/solution_invitation_urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from partner_programs.solution_invitation_api import ( + MySolutionInvitationsView, + SolutionInvitationActionView, + SolutionInvitationDetailView, +) + +urlpatterns = [ + path("", MySolutionInvitationsView.as_view()), + path("/", SolutionInvitationDetailView.as_view()), + path( + "/accept/", + SolutionInvitationActionView.as_view(decision="accept"), + ), + path( + "/decline/", + SolutionInvitationActionView.as_view(decision="decline"), + ), +] diff --git a/partner_programs/solution_invitations.py b/partner_programs/solution_invitations.py new file mode 100644 index 00000000..29fcbb7c --- /dev/null +++ b/partner_programs/solution_invitations.py @@ -0,0 +1,206 @@ +"""Приглашения решения используют общий с командой порядок блокировок.""" + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.http import Http404 + +from partner_programs import solution_team +from partner_programs.solution_errors import MAX_ID, SolutionError, issue +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) + + +def visible_invitations(actor): + return SolutionInvitation.objects.filter( + Q(recipient=actor) | Q(solution__captain=actor) + ).select_related("recipient", "solution__program", "solution__captain") + + +def require_enabled(solution): + if solution.program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + solution_team.require_active(solution) + + +def require_pending(invitation): + if invitation.status != "pending": + raise SolutionError("invite_already_processed", "Приглашение уже обработано.") + + +def require_version(invitation, expected): + if str(invitation.version) != str(expected): + raise SolutionError( + "version_conflict", + "Приглашение изменилось. Обновите данные и повторите действие.", + meta={"current_version": invitation.version}, + ) + + +def require_recipient(invitation, actor): + if invitation.recipient_id != actor.pk: + raise SolutionError( + "not_invite_recipient", + "Ответить на приглашение может только получатель.", + status=403, + ) + + +def require_eligible(solution, user_id): + if user_id in solution_team.accepted_user_ids(solution): + raise SolutionError( + "already_solution_member", "Пользователь уже состоит в команде." + ) + + +def require_role(value): + if not isinstance(value, str) or not 1 <= len(value) <= 128 or not value.strip(): + detail = "Укажите роль от 1 до 128 символов." + raise SolutionError( + "validation_error", + detail, + status=400, + errors=[issue("role", "invalid", detail)], + ) + + +def require_fields(fields, *, creating=False): + if fields.keys() - {"role", "specialization", "motivational_letter"}: + raise SolutionError( + "validation_error", "Недопустимые поля приглашения.", status=400 + ) + if creating or "role" in fields: + require_role(fields.get("role")) + + +def lock_invitation(invitation_id, actor): + if not 0 < invitation_id <= MAX_ID: + raise Http404 + try: + reference = visible_invitations(actor).get(pk=invitation_id) + except SolutionInvitation.DoesNotExist: + raise Http404 + program = solution_team.program_for(reference.solution.program_id, lock=True) + solution = ProgramSolution.all_objects.select_for_update().get( + pk=reference.solution_id, program=program + ) + try: + invitation = SolutionInvitation.objects.select_for_update().get( + pk=invitation_id, solution=solution + ) + except SolutionInvitation.DoesNotExist: + raise Http404 + solution.program = program + invitation.solution = solution + # Капитан мог смениться, пока ожидалась общая блокировка программы. + if actor.pk not in (solution.captain_id, invitation.recipient_id): + raise Http404 + return solution, invitation + + +def advance_invitation(invitation, *, fields): + invitation.version += 1 + invitation.save(update_fields=[*fields, "version", "updated_at"]) + solution_team.advance_version(invitation.solution) + return invitation + + +@transaction.atomic +def create_invitation( + *, + program_id, + solution_id, + actor, + expected_solution_version, + recipient_user_id, + **fields +): + require_fields(fields, creating=True) + solution = solution_team.lock_solution(program_id, solution_id, actor) + solution_team.require_captain(solution, actor) + solution_team.require_mutable(solution) + solution_team.require_version(solution, expected_solution_version) + if ( + not 0 < recipient_user_id <= MAX_ID + or not get_user_model().objects.filter(pk=recipient_user_id).exists() + ): + raise Http404 + require_eligible(solution, recipient_user_id) + pending = solution.invitations.filter( + recipient_id=recipient_user_id, status="pending" + ) + if pending.exists(): + raise SolutionError( + "duplicate_pending_invite", "Приглашение этому участнику уже отправлено." + ) + solution_team.validate_new_member(solution, recipient_user_id) + try: + with transaction.atomic(): + invitation = SolutionInvitation.objects.create( + solution=solution, recipient_id=recipient_user_id, **fields + ) + except IntegrityError as error: + if pending.exists(): + raise SolutionError( + "duplicate_pending_invite", "Приглашение этому участнику уже отправлено." + ) from error + raise + solution_team.advance_version(solution) + return invitation + + +@transaction.atomic +def update_invitation( + *, invitation_id, actor, expected_version, expected_solution_version, **fields +): + require_fields(fields) + solution, invitation = lock_invitation(invitation_id, actor) + solution_team.require_captain(solution, actor) + solution_team.require_mutable(solution) + require_pending(invitation) + require_version(invitation, expected_version) + solution_team.require_version(solution, expected_solution_version) + require_eligible(solution, invitation.recipient_id) + solution_team.validate_new_member(solution, invitation.recipient_id) + for field, value in fields.items(): + setattr(invitation, field, value) + return advance_invitation(invitation, fields=fields) + + +@transaction.atomic +def revoke_invitation( + *, invitation_id, actor, expected_version, expected_solution_version +): + solution, invitation = lock_invitation(invitation_id, actor) + solution_team.require_captain(solution, actor) + require_enabled(solution) + require_pending(invitation) + require_version(invitation, expected_version) + solution_team.require_version(solution, expected_solution_version) + invitation.delete() + solution_team.advance_version(solution) + + +@transaction.atomic +def decide_invitation(*, invitation_id, actor, expected_version, accept): + solution, invitation = lock_invitation(invitation_id, actor) + require_recipient(invitation, actor) + require_enabled(solution) + require_pending(invitation) + require_version(invitation, expected_version) + if accept: + require_eligible(solution, actor.pk) + solution_team.validate_new_member(solution, actor.pk) + ProgramSolutionMember.objects.create( + solution=solution, + user=actor, + role=invitation.role, + specialization=invitation.specialization, + ) + invitation.status = "accepted" if accept else "declined" + return advance_invitation(invitation, fields=["status"]) diff --git a/partner_programs/solution_lifecycle_signals.py b/partner_programs/solution_lifecycle_signals.py new file mode 100644 index 00000000..fb189117 --- /dev/null +++ b/partner_programs/solution_lifecycle_signals.py @@ -0,0 +1,62 @@ +from django.db.models.deletion import ProtectedError +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, + SolutionAdministrativeChange, + SolutionRegistrationRemoval, + lock_saved_solution, +) + + +@receiver( + pre_delete, + sender=SolutionRegistrationRemoval, + dispatch_uid="protect_solution_registration_removal_audit", +) +@receiver( + pre_delete, + sender=SolutionAdministrativeChange, + dispatch_uid="protect_solution_administrative_history", +) +def protect_administrative_history(sender, instance, **kwargs): + raise ProtectedError("Историю изменений нельзя удалять.", [instance]) + + +@receiver(pre_delete, sender=ProgramSolution, dispatch_uid="protect_submitted_solution") +@receiver( + pre_delete, sender=ProgramSolutionAnswer, dispatch_uid="protect_submitted_answer" +) +@receiver( + pre_delete, sender=ProgramSolutionMember, dispatch_uid="protect_submitted_member" +) +@receiver( + pre_delete, sender=SolutionInvitation, dispatch_uid="protect_submitted_invitation" +) +def protect_submitted_record(sender, instance, **kwargs): + solution = lock_saved_solution( + instance.pk if sender is ProgramSolution else instance.solution_id + ) + if sender is ProgramSolutionMember: + from partner_programs.services.solution_member_actions import ( + permits_member_deletion, + ) + + if permits_member_deletion(instance): + return + if ( + solution + and (solution.status == "submitted" or solution.archived_at is not None) + and not ( + solution.archived_at is None + and sender is SolutionInvitation + and instance.status == "pending" + ) + ): + raise ProtectedError( + "Данные и состав сданного решения зафиксированы.", [instance] + ) diff --git a/partner_programs/solution_member_action_api.py b/partner_programs/solution_member_action_api.py new file mode 100644 index 00000000..3b725cd4 --- /dev/null +++ b/partner_programs/solution_member_action_api.py @@ -0,0 +1,47 @@ +"""Program-scoped organizer member removal with signed confirmation.""" + +from rest_framework.response import Response + +from partner_programs.services import solution_member_actions +from partner_programs.solution_api import ( + CommandInteger, + SolutionAPIView, + StrictSerializer, +) +from partner_programs.solution_case_action_api import ExactPreviewToken +from partner_programs.solution_errors import MAX_ID + + +class MemberRemovalPreviewSerializer(StrictSerializer): + user_id = CommandInteger(min_value=1, max_value=MAX_ID) + + +class MemberRemovalApplySerializer(MemberRemovalPreviewSerializer): + preview_token = ExactPreviewToken( + max_length=200000, trim_whitespace=False, allow_blank=False + ) + + +class SolutionMemberRemovalView(SolutionAPIView): + apply_action = False + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + def post(self, request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = ( + MemberRemovalApplySerializer + if self.apply_action + else MemberRemovalPreviewSerializer + )(data=request.data) + serializer.is_valid(raise_exception=True) + command = ( + solution_member_actions.apply + if self.apply_action + else solution_member_actions.preview + ) + return Response(command(pk, solution_id, request.user, serializer.validated_data)) diff --git a/partner_programs/solution_models.py b/partner_programs/solution_models.py index fe50a3cb..959668da 100644 --- a/partner_programs/solution_models.py +++ b/partner_programs/solution_models.py @@ -1,7 +1,13 @@ """Решение владеет составом, приглашениями и ответами без Project или Team.""" from django.conf import settings -from django.db import models +from django.core.exceptions import ValidationError +from django.db import models, transaction + + +class ActiveSolutionManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(archived_at__isnull=True) class ProgramSolution(models.Model): @@ -20,15 +26,116 @@ class ProgramSolution(models.Model): ) submitted_at = models.DateTimeField(null=True, blank=True) submission_snapshot = models.JSONField(null=True, blank=True) + published_at = models.DateTimeField( + null=True, blank=True, editable=False, verbose_name="Дата публикации" + ) + archived_at = models.DateTimeField(null=True, editable=False) + archived_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.SET_NULL, + related_name="archived_program_solutions", + editable=False, + ) + archive_reason = models.CharField( + max_length=32, + null=True, + editable=False, + choices=[ + ("detached", "Снятие заявки"), + ("registration_removed", "Удаление регистрации"), + ], + ) + archive_snapshot = models.JSONField(null=True, editable=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = ActiveSolutionManager() + all_objects = models.Manager() + + @transaction.atomic + def save(self, *args, **kwargs): + previous = lock_saved_solution(self.pk) if self.pk else None + # Дату устанавливает publisher. Обычный save, в том числе устаревшего + # экземпляра, не публикует работу и не отзывает состоявшуюся публикацию. + self.published_at = previous.published_at if previous else None + for field in ( + "archived_at", + "archived_by_id", + "archive_reason", + "archive_snapshot", + ): + setattr(self, field, getattr(previous, field) if previous else None) + if previous: + if previous.archived_at is not None: + raise ValidationError( + "Решение снято с участия.", code="solution_archived" + ) + if previous.program_id != self.program_id: + raise ValidationError( + "Решение нельзя переназначить другой программе.", + code="invalid_program_context", + ) + selected = kwargs.get("update_fields") + frozen_fields = ( + "program", + "captain", + "name", + "status", + "submitted_at", + "submission_snapshot", + ) + if ( + previous.status == "submitted" or previous.archived_at is not None + ) and any( + (selected is None or field in selected or field + "_id" in selected) + and getattr( + previous, field + "_id" if field in ("program", "captain") else field + ) + != getattr( + self, field + "_id" if field in ("program", "captain") else field + ) + for field in frozen_fields + ): + raise ValidationError( + "Сданное решение нельзя изменять или возвращать в черновик.", + code="team_frozen", + ) + return super().save(*args, **kwargs) class Meta: + base_manager_name = "all_objects" + default_manager_name = "objects" + verbose_name = "Решение программы" + verbose_name_plural = "Решения программ" ordering = ["created_at", "pk"] + indexes = [ + models.Index( + fields=["program", "published_at", "id"], + condition=models.Q(published_at__isnull=False), + name="solution_publication_idx", + ) + ] constraints = [ models.UniqueConstraint( fields=["program", "captain"], - name="unique_solution_program_captain", + condition=models.Q(archived_at__isnull=True), + name="unique_active_solution_captain", + ), + models.CheckConstraint( + check=( + models.Q( + archived_at__isnull=True, + archive_reason__isnull=True, + archive_snapshot__isnull=True, + ) + | models.Q( + archived_at__isnull=False, + archive_reason__isnull=False, + archive_reason__in=["detached", "registration_removed"], + archive_snapshot__isnull=False, + ) + ), + name="solution_archive_consistent", ), models.CheckConstraint( check=models.Q(version__gte=1), name="solution_version_positive" @@ -51,7 +158,220 @@ class Meta: ] -class ProgramSolutionMember(models.Model): +class RegistrationRemovalQuerySet(models.QuerySet): + def update(self, **kwargs): + raise ValidationError("Аудит удаления регистраций нельзя переписывать.") + + def delete(self): + raise ValidationError("Аудит удаления регистраций нельзя удалять.") + + +class SolutionRegistrationRemoval(models.Model): + """Audit exists even when removed registrations had no solution.""" + + program = models.ForeignKey( + "partner_programs.PartnerProgram", + on_delete=models.PROTECT, + related_name="solution_registration_removals", + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + actor_name = models.CharField(max_length=512) + registrations = models.JSONField() + deleted_count = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True) + objects = RegistrationRemovalQuerySet.as_manager() + + def save(self, *args, **kwargs): + if self.pk is not None: + raise ValidationError("Аудит удаления регистраций нельзя переписывать.") + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise ValidationError("Аудит удаления регистраций нельзя удалять.") + + class Meta: + ordering = ["-created_at", "-pk"] + verbose_name = "Удаление регистраций решений" + verbose_name_plural = "Аудит удаления регистраций решений" + constraints = [ + models.CheckConstraint( + check=models.Q(deleted_count__gte=1), + name="solution_registration_removal_positive", + ) + ] + + +class SolutionAdministrativeChange(models.Model): + """Privileged changes retain the original submission and every later revision.""" + + solution = models.ForeignKey( + ProgramSolution, on_delete=models.PROTECT, related_name="administrative_changes" + ) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + actor_name = models.CharField(max_length=512) + action = models.CharField( + max_length=32, + choices=[ + ("change_case", "Смена кейса"), + ("remove_member", "Исключение участника"), + ("archive", "Снятие заявки"), + ("remove_registrations", "Удаление регистраций"), + ], + ) + before_version = models.PositiveBigIntegerField() + after_version = models.PositiveBigIntegerField() + case_field_id = models.PositiveBigIntegerField(null=True) + previous_case = models.TextField(null=True) + case_name = models.CharField(max_length=1000, null=True) + removed_member = models.JSONField(null=True) + removed_members = models.JSONField(null=True) + registration_removal = models.ForeignKey( + SolutionRegistrationRemoval, + null=True, + on_delete=models.PROTECT, + related_name="changes", + ) + revoked_invitation_ids = models.JSONField(default=list) + archived_at = models.DateTimeField(null=True) + archive_reason = models.CharField(max_length=32, null=True) + archive_snapshot = models.JSONField(null=True) + removed_assignment_ids = models.JSONField(default=list) + score_count = models.PositiveIntegerField(default=0) + was_submitted = models.BooleanField() + before_snapshot = models.JSONField(null=True) + after_snapshot = models.JSONField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if self.pk is not None: + raise ValidationError("Историю изменений нельзя переписывать.") + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + raise ValidationError("Историю изменений нельзя удалять.") + + class Meta: + verbose_name = "Административное изменение решения" + verbose_name_plural = "История административных изменений решений" + ordering = ["-created_at", "-pk"] + constraints = [ + models.CheckConstraint( + check=( + models.Q( + action="change_case", + case_field_id__isnull=False, + case_name__isnull=False, + removed_member__isnull=True, + removed_members__isnull=True, + registration_removal__isnull=True, + archived_at__isnull=True, + archive_reason__isnull=True, + archive_snapshot__isnull=True, + ) + | models.Q( + action="remove_member", + case_field_id__isnull=True, + previous_case__isnull=True, + case_name__isnull=True, + removed_member__isnull=False, + removed_members__isnull=True, + registration_removal__isnull=True, + archived_at__isnull=True, + archive_reason__isnull=True, + archive_snapshot__isnull=True, + ) + | models.Q( + action="archive", + removed_members__isnull=True, + case_field_id__isnull=True, + previous_case__isnull=True, + case_name__isnull=True, + removed_member__isnull=True, + archived_at__isnull=False, + archive_reason__in=["detached", "registration_removed"], + archive_reason__isnull=False, + archive_snapshot__isnull=False, + ) + | models.Q( + action="remove_registrations", + case_field_id__isnull=True, + previous_case__isnull=True, + case_name__isnull=True, + removed_member__isnull=True, + removed_members__isnull=False, + registration_removal__isnull=False, + archived_at__isnull=True, + archive_reason__isnull=True, + archive_snapshot__isnull=True, + ) + ), + name="solution_admin_action_consistent", + ), + models.UniqueConstraint( + fields=["solution", "after_version"], + name="unique_solution_admin_revision", + ), + models.CheckConstraint( + check=models.Q(before_version__gte=1) + & models.Q(after_version=models.F("before_version") + 1), + name="solution_admin_version_step", + ), + models.CheckConstraint( + check=( + models.Q( + was_submitted=True, + before_snapshot__isnull=False, + after_snapshot__isnull=False, + ) + | models.Q( + was_submitted=False, + before_snapshot__isnull=True, + after_snapshot__isnull=True, + ) + ), + name="solution_admin_snapshots_consistent", + ), + ] + + +class SolutionOwnedRecord(models.Model): + """Обычные model/admin записи используют ту же сериализацию, что и API.""" + + class Meta: + abstract = True + + @transaction.atomic + def save(self, *args, **kwargs): + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + if previous and previous.solution_id != self.solution_id: + raise ValidationError( + "Запись нельзя переносить в другую заявку.", + code="invalid_program_context", + ) + solution = lock_saved_solution(self.solution_id) if self.solution_id else None + if solution and solution.archived_at is not None: + raise ValidationError("Решение снято с участия.", code="solution_archived") + # Другой writer мог обработать запись, пока мы ожидали блокировку программы. + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + if ( + solution + and (solution.status == "submitted" or solution.archived_at is not None) + and not self.allowed_after_submission(previous, kwargs.get("update_fields")) + ): + raise ValidationError( + "Данные и состав сданного решения зафиксированы.", code="team_frozen" + ) + return super().save(*args, **kwargs) + + def allowed_after_submission(self, previous, selected): + return False + + +class ProgramSolutionMember(SolutionOwnedRecord): solution = models.ForeignKey( ProgramSolution, on_delete=models.CASCADE, related_name="members" ) @@ -69,7 +389,7 @@ class Meta: ] -class SolutionInvitation(models.Model): +class SolutionInvitation(SolutionOwnedRecord): solution = models.ForeignKey( ProgramSolution, on_delete=models.CASCADE, related_name="invitations" ) @@ -90,6 +410,28 @@ class SolutionInvitation(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + def allowed_after_submission(self, previous, selected): + # Pending может быть отклонен; принятие, создание и изменение роли закрыты. + return ( + previous is not None + and previous.status == "pending" + and self.status == "declined" + and all( + ( + selected is not None + and field not in selected + and field.removesuffix("_id") not in selected + ) + or getattr(previous, field) == getattr(self, field) + for field in ( + "recipient_id", + "role", + "specialization", + "motivational_letter", + ) + ) + ) + class Meta: constraints = [ models.UniqueConstraint( @@ -107,7 +449,7 @@ class Meta: ] -class ProgramSolutionAnswer(models.Model): +class ProgramSolutionAnswer(SolutionOwnedRecord): solution = models.ForeignKey( ProgramSolution, on_delete=models.CASCADE, related_name="answers" ) @@ -124,3 +466,18 @@ class Meta: fields=["solution", "field"], name="unique_solution_answer" ) ] + + +def lock_saved_solution(solution_id): + """Вызывается только внутри atomic: программа -> решение, как у команд API.""" + reference = ( + ProgramSolution.all_objects.filter(pk=solution_id) + .values_list("program_id", flat=True) + .first() + ) + if reference is None: + return None + from partner_programs.models import PartnerProgram + + PartnerProgram.objects.select_for_update().get(pk=reference) + return ProgramSolution.all_objects.select_for_update().filter(pk=solution_id).first() diff --git a/partner_programs/solution_participants.py b/partner_programs/solution_participants.py new file mode 100644 index 00000000..5e23be17 --- /dev/null +++ b/partner_programs/solution_participants.py @@ -0,0 +1,35 @@ +"""Принятая команда решения; анкета и ожидающее приглашение не доказывают членство.""" + +from django.db.models import Exists, OuterRef + +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember + + +def uses_solutions(program_id): + return PartnerProgram.objects.filter( + pk=program_id, solution_storage_mode="solution" + ).exists() + + +def membership_expressions(program_id): + captains = ProgramSolution.objects.filter( + program_id=program_id, captain_id=OuterRef("user_id") + ) + members = ProgramSolutionMember.objects.filter( + solution__program_id=program_id, + solution__archived_at__isnull=True, + user_id=OuterRef("user_id"), + ) + # Сохраняем internal aliases прежней SQL-проекции для общих счетчиков и списков. + return dict( + is_project_leader=Exists(captains), + is_submitted_project_leader=Exists(captains.filter(status="submitted")), + is_project_collaborator=Exists(members), + ) + + +def participant_profiles(program_id, profiles=None): + if profiles is None: + profiles = PartnerProgramUserProfile.objects.filter(partner_program_id=program_id) + return profiles.annotate(**membership_expressions(program_id)) diff --git a/partner_programs/solution_publication_api.py b/partner_programs/solution_publication_api.py new file mode 100644 index 00000000..4b7470f4 --- /dev/null +++ b/partner_programs/solution_publication_api.py @@ -0,0 +1,78 @@ +"""Анонимное чтение allowlist опубликованных материалов решения.""" + +from django.http import Http404 +from rest_framework import serializers +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from partner_programs.services.solution_publication import ( + published_solutions, + public_card, + public_materials, +) +from partner_programs.solution_api import SolutionAPIView, StrictSerializer +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_team import program_for + + +class PublishedSolutionQuery(StrictSerializer): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + search = serializers.CharField(allow_blank=True, max_length=200, default="") + + +class PublishedSolutionAPIView(SolutionAPIView): + permission_classes = [AllowAny] + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "no-store" + return response + + def program(self, pk): + program = program_for(pk) + if program.solution_storage_mode != "solution": + raise Http404 + return program + + +class PublishedSolutionListView(PublishedSolutionAPIView): + def get(self, request, pk): + program = self.program(pk) + query = PublishedSolutionQuery(data=request.query_params) + query.is_valid(raise_exception=True) + data = query.validated_data + rows = published_solutions(program, data["search"]) + count, limit, offset = rows.count(), data["limit"], data["offset"] + stop = offset + limit + + def page_url(position): + params = request.query_params.copy() + params["limit"], params["offset"] = str(limit), str(position) + return request.build_absolute_uri(request.path + "?" + params.urlencode()) + + return Response( + dict( + program_id=program.pk, + count=count, + next=page_url(stop) if stop < count else None, + previous=page_url(max(0, offset - limit)) if offset else None, + results=[public_card(row) for row in rows[offset:stop]], + ) + ) + + +class PublishedSolutionDetailView(PublishedSolutionAPIView): + def get(self, request, pk, solution_id): + program = self.program(pk) + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + if not 0 < solution_id <= MAX_ID: + raise Http404 + solution = ( + published_solutions(program, with_answers=True).filter(pk=solution_id).first() + ) + if solution is None: + raise Http404 + solution.program = program + return Response(public_materials(solution)) diff --git a/partner_programs/solution_registration_action_api.py b/partner_programs/solution_registration_action_api.py new file mode 100644 index 00000000..63b4588c --- /dev/null +++ b/partner_programs/solution_registration_action_api.py @@ -0,0 +1,111 @@ +"""Program-specific confirmed registration removal and retained audit reads.""" + +from django.http import Http404 +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.services import solution_registration_actions as actions +from partner_programs.solution_api import ( + CommandInteger, + SolutionAPIView, + StrictSerializer, + iso, + user_data, +) +from partner_programs.solution_case_action_api import ExactPreviewToken +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_history_api import HistoryQuery, change_data +from partner_programs.solution_models import SolutionRegistrationRemoval + + +class RemovalPreview(StrictSerializer): + registration_ids = serializers.ListField( + child=CommandInteger(min_value=1, max_value=MAX_ID), + allow_empty=False, + max_length=10000, + ) + + def validate_registration_ids(self, ids): + if len(ids) != len(set(ids)): + raise serializers.ValidationError("Выберите регистрации без повторов.") + return sorted(ids) + + +class RemovalApply(RemovalPreview): + preview_token = ExactPreviewToken( + max_length=200000, trim_whitespace=False, allow_blank=False + ) + + +class PrivateView(SolutionAPIView): + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + return response + + +class SolutionRegistrationRemovalView(PrivateView): + apply_action = False + + def post(self, request, pk): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + command = (RemovalApply if self.apply_action else RemovalPreview)( + data=request.data + ) + command.is_valid(raise_exception=True) + writer = actions.apply if self.apply_action else actions.preview + return Response(writer(pk, request.user, command.validated_data)) + + +def audit_data(audit): + return dict( + id=audit.pk, + program_id=audit.program_id, + actor=user_data(audit.actor) if audit.actor else None, + actor_name=audit.actor_name, + created_at=iso(audit.created_at), + registrations=audit.registrations, + deleted_count=audit.deleted_count, + ) + + +class SolutionRegistrationRemovalHistoryView(PrivateView): + def get(self, request, pk, audit_id=None): + program = actions.managed_program(pk, request.user) + query = (HistoryQuery if audit_id is None else StrictSerializer)( + data=request.query_params + ) + query.is_valid(raise_exception=True) + rows = SolutionRegistrationRemoval.objects.filter(program=program).select_related( + "actor" + ) + if audit_id is not None: + if not 0 < audit_id <= MAX_ID: + raise Http404 + try: + audit = rows.get(pk=audit_id) + except SolutionRegistrationRemoval.DoesNotExist: + raise Http404 + return Response( + dict( + audit=audit_data(audit), + changes=[ + change_data(c) + for c in audit.changes.select_related( + "actor", "solution" + ).order_by("pk") + ], + ) + ) + offset, limit = query.validated_data["offset"], query.validated_data["limit"] + return Response( + dict( + count=rows.count(), + offset=offset, + limit=limit, + results=[ + audit_data(a) for a in rows[offset : offset + limit] # noqa: E203 + ], + ) + ) diff --git a/partner_programs/solution_schema.py b/partner_programs/solution_schema.py new file mode 100644 index 00000000..27014f99 --- /dev/null +++ b/partner_programs/solution_schema.py @@ -0,0 +1,272 @@ +"""Общие определения формы и проверки значений без изменения старого Project.""" + +import hashlib +import json +import math +from urllib.parse import urlsplit + +from partner_programs.solution_errors import SolutionError, issue + +FIELD_TYPES = ("text", "textarea", "url", "number", "select", "checkbox", "file", "radio") +PURPOSES = ( + "other", + "case", + "presentation", + "repository", + "demo", + "video", + "run_instructions", +) +TEMPLATES = ("case_championship", "hackathon") +MAX_FILE_SIZE = 104857600 +VALIDATION_DEFAULTS = dict( + min_length=None, + max_length=None, + minimum=None, + maximum=None, + allowed_extensions=[], + max_file_size_bytes=None, +) + + +def empty_value(value): + return value is None or isinstance(value, str) and not value.strip() + + +def field_data(field): + validation = field.validation if isinstance(field.validation, dict) else {} + return dict( + id=field.pk, + type=field.field_type, + purpose="case" if field.name == "case" else field.purpose, + label=field.label, + help_text=field.help_text or "", + required=field.is_required, + position=field.position, + options=field.get_options_list(), + validation={ + k: list(validation.get(k, v)) + if k == "allowed_extensions" + else validation.get(k, v) + for k, v in VALIDATION_DEFAULTS.items() + }, + ) + + +def form_data(program): + fields = [field_data(field) for field in program.fields.order_by("position", "pk")] + definition = dict(template=program.solution_template, fields=fields) + revision = hashlib.sha256( + json.dumps( + definition, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode() + ).hexdigest() + configured = program.solution_template in TEMPLATES and any( + f["purpose"] == "case" + and f["type"] == "select" + and f["required"] + and f["options"] + for f in fields + ) + return dict( + program_id=program.pk, + revision=revision, + template=program.solution_template, + is_configured=bool(configured), + is_snapshot=False, + fields=fields, + ) + + +def definition_error(path, detail, field_id=None, *, code="invalid", status=400): + raise SolutionError( + "validation_error" if status == 400 else "field_in_use", + detail, + status=status, + errors=[issue(path, code, detail, field_id)], + ) + + +def validate_definition(field): + field_id = field.get("id") + path = f"fields.{field_id}" if field_id else "fields" + kind, purpose, validation = field["type"], field["purpose"], field["validation"] + if not field["label"].strip(): + definition_error(path + ".label", "Укажите подпись поля.", field_id) + required_types = dict( + case="select", + presentation="file", + repository="url", + demo="url", + video="url", + run_instructions="textarea", + ) + if purpose in required_types and kind != required_types[purpose]: + definition_error( + path + ".type", "Тип не соответствует назначению поля.", field_id + ) + if purpose == "case" and not field["required"]: + definition_error( + path + ".required", "Выбор кейса или задачи обязателен.", field_id + ) + options = field["options"] + if kind in ("select", "radio"): + if not options or any(not value.strip() for value in options): + definition_error( + path + ".options", "Добавьте непустые варианты выбора.", field_id + ) + if len({value.strip().casefold() for value in options}) != len(options): + definition_error( + path + ".options", "Варианты не должны повторяться.", field_id + ) + elif options: + definition_error( + path + ".options", "Варианты доступны только полям выбора.", field_id + ) + for lower, upper in (("min_length", "max_length"), ("minimum", "maximum")): + if validation[lower] is not None and validation[upper] is not None: + if validation[lower] > validation[upper]: + definition_error( + path + ".validation", "Минимум превышает максимум.", field_id + ) + if kind not in ("text", "textarea", "url"): + if any(validation[k] is not None for k in ("min_length", "max_length")): + definition_error( + path + ".validation", "Длина задается для текстового поля.", field_id + ) + if kind != "number" and any( + validation[k] is not None for k in ("minimum", "maximum") + ): + definition_error(path + ".validation", "Диапазон задается для числа.", field_id) + if kind != "file" and ( + validation["allowed_extensions"] or validation["max_file_size_bytes"] + ): + definition_error( + path + ".validation", "Размер и расширения задаются для файла.", field_id + ) + extensions = validation["allowed_extensions"] + if len(set(extensions)) != len(extensions): + definition_error( + path + ".validation", "Расширения не должны повторяться.", field_id + ) + + +def value_problem(field, value): + """None означает допустимый непустой ответ; required проверяется отдельно.""" + kind, validation = field["type"], field["validation"] + if kind in ("text", "textarea", "url", "select", "radio") and not isinstance( + value, str + ): + return "invalid_type", "Ожидается текст." + if kind in ("select", "radio") and value not in field["options"]: + return "invalid_choice", "Выберите актуальный вариант." + if kind == "checkbox" and not isinstance(value, bool): + return "invalid_type", "Ожидается логическое значение." + if kind == "number": + try: + finite = math.isfinite(value) if isinstance(value, (int, float)) else False + except OverflowError: + finite = False + if isinstance(value, bool) or not isinstance(value, (int, float)) or not finite: + return "invalid_type", "Ожидается конечное число." + if validation["minimum"] is not None and value < validation["minimum"]: + return "minimum", "Число меньше допустимого." + if validation["maximum"] is not None and value > validation["maximum"]: + return "maximum", "Число больше допустимого." + if kind in ("text", "textarea", "url"): + if validation["min_length"] is not None and len(value) < validation["min_length"]: + return "min_length", "Текст короче допустимого." + if validation["max_length"] is not None and len(value) > validation["max_length"]: + return "max_length", "Текст длиннее допустимого." + if kind == "url": + try: + url = urlsplit(value) + if ( + url.scheme not in ("http", "https") + or not url.hostname + or url.username + or url.password + ): + return "invalid_url", "Укажите ссылку HTTP или HTTPS." + if any(char.isspace() for char in value): + return "invalid_url", "Ссылка не должна содержать пробелы." + url.port + except ValueError: + return "invalid_url", "Укажите корректную ссылку." + if kind == "file": + if ( + not isinstance(value, dict) + or set(value) != {"url"} + or not isinstance(value["url"], str) + ): + return "invalid_type", "Укажите один загруженный файл." + return None + + +def template_fields(): + def field(kind, purpose, label, required=False, **validation): + return dict( + id=None, + type=kind, + purpose=purpose, + label=label, + help_text="", + required=required, + position=0, + options=[], + validation={**VALIDATION_DEFAULTS, "allowed_extensions": [], **validation}, + ) + + def case(label): + return field("select", "case", label, True) + + def description(): + return field("textarea", "other", "Краткое описание", True, max_length=3000) + + def presentation(required): + return field( + "file", + "presentation", + "Презентация PDF", + required, + allowed_extensions=["pdf"], + max_file_size_bytes=20971520, + ) + + def extras(): + return [ + field("url", "video", "Видео"), + field( + "file", + "other", + "Дополнительные материалы", + allowed_extensions=["pdf", "zip"], + max_file_size_bytes=52428800, + ), + ] + + rows = [ + dict( + key="case_championship", + label="Кейс-чемпионат", + fields=[case("Кейс"), description(), presentation(True), *extras()], + ), + dict( + key="hackathon", + label="Хакатон", + fields=[ + case("Задача или трек"), + description(), + field("textarea", "other", "Описание реализации", True, max_length=10000), + field("url", "repository", "Репозиторий", True), + field("textarea", "run_instructions", "Инструкция запуска", True), + field("url", "demo", "Демо"), + presentation(False), + *extras(), + ], + ), + ] + for template in rows: + for position, row in enumerate(template["fields"]): + row["position"] = position + return dict(results=rows, max_file_size_bytes=MAX_FILE_SIZE, files_per_field=1) diff --git a/partner_programs/solution_submission.py b/partner_programs/solution_submission.py new file mode 100644 index 00000000..b7546a91 --- /dev/null +++ b/partner_programs/solution_submission.py @@ -0,0 +1,248 @@ +"""Проверка готовности и атомарная однократная сдача решения.""" + +import copy + +from django.db import transaction +from django.utils import timezone + +from files.models import UserFile +from partner_programs.solution_errors import SolutionError, issue +from partner_programs.solution_files import ( + owned_file_urls, + validate_file, + MIME_BY_EXTENSION, +) +from partner_programs.solution_schema import form_data, value_problem +from partner_programs.solution_team import ( + lock_solution, + require_captain, + require_mutable, + require_version, + validate_submission_team, + advance_version, +) + + +def answer_issues(form, answers): + values = {answer["field_id"]: answer["value"] for answer in answers} + definitions = {field["id"]: field for field in form["fields"]} + problems = [] + for field in form["fields"]: + value = values.get(field["id"]) + empty = value is None or ( + field["type"] in ("text", "textarea", "url", "select", "radio") + and isinstance(value, str) + and not value.strip() + ) + if empty: + if field["required"]: + problems.append( + issue( + "answers", + "case_required" if field["purpose"] == "case" else "required", + "Выберите кейс или задачу." + if field["purpose"] == "case" + else "Заполните обязательное поле.", + field["id"], + ) + ) + continue + problem = value_problem(field, value) + if problem: + code, detail = problem + if field["purpose"] == "case": + code = "case_unavailable" + problems.append(issue("answers", code, detail, field["id"])) + for field_id in values.keys() - definitions.keys(): + problems.append( + issue( + "answers", + "unknown_field", + "Поле не относится к форме этой программы.", + field_id, + ) + ) + return problems + + +def readiness_issues(solution, form, answers, accepted): + """Чтение не делает сетевых запросов и не берет writer-блокировки.""" + from projects import team_policy + from projects.team_errors import TeamError + + problems = [] + if not solution.name.strip(): + problems.append(issue("name", "required", "Укажите название решения.")) + program = solution.program + try: + team_policy.validate_program_users( + program, project_id=None, solution_id=solution.pk, user_ids=accepted + ) + except TeamError as error: + problems.append( + issue("team", str(error.detail["code"]), str(error.detail["detail"])) + ) + if ( + program.legacy_team_min_size is not None + and len(accepted) < program.legacy_team_min_size + ): + problems.append( + issue("team", "team_min_size", "В команде недостаточно принятых участников.") + ) + if ( + program.legacy_team_max_size is not None + and len(accepted) > program.legacy_team_max_size + ): + problems.append( + issue("team", "team_max_size", "Превышен максимальный размер команды.") + ) + if not form["is_configured"]: + problems.append( + issue( + "form", + "form_not_configured", + "Организатор еще не настроил форму решения.", + ) + ) + problems.extend(answer_issues(form, answers)) + file_answers = { + answer["field_id"]: answer["value"] + for answer in answers + if isinstance(answer["value"], dict) + and isinstance(answer["value"].get("url"), str) + } + uploads = ( + UserFile.objects.in_bulk([value["url"] for value in file_answers.values()]) + if file_answers + else {} + ) + for field in form["fields"]: + if field["type"] != "file" or field["id"] not in file_answers: + continue + upload = uploads.get(file_answers[field["id"]]["url"]) + problem = None + if upload is None: + problem = ("file_not_found", "Файл не найден. Загрузите его повторно.") + elif upload.size > min( + field["validation"]["max_file_size_bytes"] or 104857600, 104857600 + ): + problem = ("file_too_large", "Файл превышает допустимый размер.") + elif ( + field["validation"]["allowed_extensions"] + and upload.extension.lower() not in field["validation"]["allowed_extensions"] + ): + problem = ("file_type_not_allowed", "Выберите разрешенный тип файла.") + elif ( + upload.detected_mime_type + and MIME_BY_EXTENSION.get(upload.extension.lower()) + and upload.detected_mime_type + not in MIME_BY_EXTENSION[upload.extension.lower()] + ): + problem = ( + "file_type_mismatch", + "Содержимое файла не соответствует его типу.", + ) + if problem: + problems.append(issue("answers", *problem, field["id"])) + return problems + + +def require_open(program): + if not program.is_project_submission_open(): + raise SolutionError("submission_closed", "Срок сдачи решения завершен.") + + +@transaction.atomic +def submit_solution( + *, program_id, solution_id, actor, expected_version, expected_form_revision +): + solution = lock_solution(program_id, solution_id, actor) + require_captain(solution, actor) + if solution.status == "submitted": + raise SolutionError("already_submitted", "Решение уже сдано.") + require_mutable(solution) + require_version(solution, expected_version) + list(solution.program.fields.select_for_update().order_by("pk")) + form = form_data(solution.program) + if form["revision"] != expected_form_revision: + raise SolutionError( + "form_revision_conflict", + "Форма решения изменилась. Обновите поля.", + meta={"current_form_revision": form["revision"]}, + ) + if not solution.program.is_competitive: + raise SolutionError("not_competitive", "Эта программа не требует сдачи решения.") + require_open(solution.program) + return _finalize_submission(solution, actor, form) + + +def _finalize_submission(solution, actor, form, *, allow_after_deadline=False): + """Caller holds program/solution locks and has checked its own submission right.""" + require_mutable(solution) + list(solution.members.select_for_update().order_by("pk")) + try: + validate_submission_team(solution) + except SolutionError as error: + raise SolutionError( + error.payload["code"], + error.payload["detail"], + status=error.status_code, + errors=[issue("team", error.payload["code"], error.payload["detail"])], + ) from error + if not form["is_configured"]: + raise SolutionError( + "form_not_configured", "Организатор еще не настроил форму решения." + ) + answers = [ + dict(field_id=row.field_id, value=row.value) + for row in solution.answers.order_by("field_id") + ] + problems = answer_issues(form, answers) + if not solution.name.strip(): + problems.insert(0, issue("name", "required", "Укажите название решения.")) + if problems: + codes = {problem["code"] for problem in problems} + code = next( + (code for code in ("case_required", "case_unavailable") if code in codes), + "required_program_fields" if "required" in codes else "invalid_answers", + ) + raise SolutionError( + code, "Проверьте поля решения перед сдачей.", status=400, errors=problems + ) + values = {answer["field_id"]: answer["value"] for answer in answers} + files = [ + (field, values[field["id"]]) + for field in form["fields"] + if field["type"] == "file" and values.get(field["id"]) is not None + ] + list( + UserFile.objects.select_for_update() + .filter(pk__in=[value["url"] for _, value in files]) + .order_by("pk") + ) + owned = owned_file_urls(solution) + for field, value in files: + validate_file(solution, field, value, owned_urls=owned) + # Проверка прежнего файла может потребовать I/O; срок проверяется и перед фиксацией. + from partner_programs.solution_api import solution_data, iso + + data = solution_data(solution, actor) + stamp = timezone.now() + deadline = solution.program.get_project_submission_deadline() + if not allow_after_deadline and deadline is not None and stamp > deadline: + raise SolutionError("submission_closed", "Срок сдачи решения завершен.") + solution.submission_snapshot = copy.deepcopy( + dict( + name=solution.name, + captain_id=solution.captain_id, + members=data["team"]["members"], + form={**form, "is_snapshot": True}, + answers=answers, + submitted_at=iso(stamp), + ) + ) + solution.status = "submitted" + solution.submitted_at = stamp + return advance_version( + solution, fields=["status", "submitted_at", "submission_snapshot"] + ) diff --git a/partner_programs/solution_submission_api.py b/partner_programs/solution_submission_api.py new file mode 100644 index 00000000..42491fdc --- /dev/null +++ b/partner_programs/solution_submission_api.py @@ -0,0 +1,34 @@ +from django.db import transaction +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.solution_api import ( + CommandInteger, + StrictSerializer, + SolutionAPIView, + solution_data, +) +from partner_programs.solution_submission import submit_solution + + +class SubmitSolutionSerializer(StrictSerializer): + expected_version = CommandInteger(min_value=1) + expected_form_revision = serializers.RegexField( + r"^[a-f0-9]{64}$", trim_whitespace=False + ) + + +class SolutionSubmitView(SolutionAPIView): + @transaction.atomic + def post(self, request, pk, solution_id): + query = StrictSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + serializer = SubmitSolutionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + solution = submit_solution( + program_id=pk, + solution_id=solution_id, + actor=request.user, + **serializer.validated_data + ) + return Response(solution_data(solution, request.user)) diff --git a/partner_programs/solution_team.py b/partner_programs/solution_team.py new file mode 100644 index 00000000..ab0ee52a --- /dev/null +++ b/partner_programs/solution_team.py @@ -0,0 +1,213 @@ +"""Владелец состава — решение; общие ограничения сохраняются с legacy-командой.""" + +from django.db import transaction +from django.db.models import Q +from django.http import Http404 + +from partner_programs.models import PartnerProgram +from partner_programs.permissions import can_manage_program +from partner_programs.solution_errors import MAX_ID, SolutionError +from partner_programs.solution_models import ProgramSolution +from projects import team_policy +from projects.team_errors import TeamError + + +def program_for(pk, *, lock=False): + if not 0 < pk <= MAX_ID: + raise Http404 + query = PartnerProgram.objects.select_for_update() if lock else PartnerProgram.objects + try: + return query.get(pk=pk) + except PartnerProgram.DoesNotExist: + raise Http404 + + +def visible_solutions(program, user, *, include_archived=False): + manager = ProgramSolution.all_objects if include_archived else ProgramSolution.objects + query = manager.filter(program=program) + # Эксперт получает сданные работы своей программы в пределах режима назначений. + if can_manage_program(user, program): + return query + from project_rates.solution_evaluation import expert_visible_solutions + + return query.filter( + Q(captain=user) + | Q(members__user=user) + | Q(pk__in=expert_visible_solutions(program, user).order_by().values("pk")) + ).distinct() + + +def lock_solution(program_id, solution_id, actor): + program = program_for(program_id, lock=True) + if not 0 < solution_id <= MAX_ID: + raise Http404 + # Отдельный EXISTS до FOR UPDATE: DISTINCT нельзя блокировать в PostgreSQL. + if ( + not visible_solutions(program, actor, include_archived=True) + .filter(pk=solution_id) + .exists() + ): + raise Http404 + solution = ProgramSolution.all_objects.select_for_update().get( + pk=solution_id, program=program + ) + solution.program = program + return solution + + +def require_mutable(solution): + if solution.program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + require_active(solution) + if solution.status == "submitted": + raise SolutionError("team_frozen", "Состав команды зафиксирован после сдачи.") + + +def require_active(solution): + if solution.archived_at is not None: + raise SolutionError( + "solution_archived", + "Решение снято с участия. Материалы доступны для просмотра.", + ) + + +def require_captain(solution, actor): + if solution.captain_id != actor.pk: + raise SolutionError( + "not_solution_captain", "Управлять командой может только капитан.", status=403 + ) + + +def require_version(solution, expected): + if str(solution.version) != str(expected): + raise SolutionError( + "version_conflict", + "Заявка изменилась. Обновите данные и повторите действие.", + meta={"current_version": solution.version}, + ) + + +def accepted_user_ids(solution): + return {solution.captain_id} | set(solution.members.values_list("user_id", flat=True)) + + +def reserved_user_ids(solution): + return accepted_user_ids(solution) | set( + solution.invitations.filter(status="pending").values_list( + "recipient_id", flat=True + ) + ) + + +def validate_users(solution, user_ids): + try: + team_policy.validate_program_users( + solution.program, + project_id=None, + solution_id=solution.pk, + user_ids=user_ids, + lock=True, + ) + except TeamError as error: + raise SolutionError( + str(error.detail["code"]), str(error.detail["detail"]), error.status_code + ) from error + + +def validate_max_size(solution, user_ids): + try: + team_policy.validate_max_size(solution.program, user_ids) + except TeamError as error: + raise SolutionError( + str(error.detail["code"]), str(error.detail["detail"]), error.status_code + ) from error + + +def validate_new_member(solution, user_id): + """BE03 использует под теми же блокировками; свой pending резерв не удваивается.""" + require_mutable(solution) + validate_users(solution, {user_id}) + validate_max_size(solution, reserved_user_ids(solution) | {user_id}) + + +def validate_submission_team(solution): + members = accepted_user_ids(solution) + validate_users(solution, members) + if solution.program.is_competitive: + minimum = solution.program.legacy_team_min_size + if minimum is not None and len(members) < minimum: + raise SolutionError( + "team_min_size", f"Для сдачи нужно не менее {minimum} участников." + ) + validate_max_size(solution, members) + return members + + +def advance_version(solution, *, fields=()): + solution.version += 1 + solution.save(update_fields=[*fields, "version", "updated_at"]) + return solution + + +def require_not_captain(solution, user_id): + if solution.captain_id == user_id: + raise SolutionError( + "captain_cannot_leave", "Сначала передайте капитанство другому участнику." + ) + + +@transaction.atomic +def remove_member(*, program_id, solution_id, user_id, actor, expected_version): + solution = lock_solution(program_id, solution_id, actor) + require_captain(solution, actor) + require_mutable(solution) + require_version(solution, expected_version) + require_not_captain(solution, user_id) + if not 0 < user_id <= MAX_ID or not solution.members.filter(user_id=user_id).exists(): + raise Http404 + solution.members.filter(user_id=user_id).delete() + return advance_version(solution) + + +@transaction.atomic +def leave_team(*, program_id, solution_id, actor, expected_version): + solution = lock_solution(program_id, solution_id, actor) + require_mutable(solution) + require_version(solution, expected_version) + require_not_captain(solution, actor.pk) + if not solution.members.filter(user_id=actor.pk).exists(): + raise SolutionError( + "not_solution_member", "Вы не состоите в этой команде.", status=403 + ) + solution.members.filter(user_id=actor.pk).delete() + advance_version(solution) + + +@transaction.atomic +def transfer_captain(*, program_id, solution_id, user_id, actor, expected_version): + solution = lock_solution(program_id, solution_id, actor) + require_captain(solution, actor) + require_mutable(solution) + require_version(solution, expected_version) + if user_id == solution.captain_id: + raise SolutionError( + "already_solution_captain", "Участник уже является капитаном." + ) + if not 0 < user_id <= MAX_ID or not solution.members.filter(user_id=user_id).exists(): + raise SolutionError( + "member_not_found", "Новый капитан должен быть участником команды." + ) + validate_new_member(solution, user_id) + if ( + ProgramSolution.objects.filter(program=solution.program, captain_id=user_id) + .exclude(pk=solution.pk) + .exists() + ): + raise SolutionError( + "already_solution_captain", + "Участник уже является капитаном другой заявки этой программы.", + ) + solution.captain_id = user_id + return advance_version(solution, fields=["captain"]) diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 1aae8be2..eccc9f51 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -1,7 +1,10 @@ import logging from procollab.celery import app -from partner_programs.services import publish_finished_program_projects +from partner_programs.services import ( + publish_finished_program_projects, + publish_finished_program_solutions, +) logger = logging.getLogger(__name__) @@ -137,6 +140,13 @@ def publish_finished_program_projects_task() -> int: return updated_count +@app.task +def publish_finished_program_solutions_task() -> int: + updated_count = publish_finished_program_solutions() + logger.info("Published %s program solutions after finish", updated_count) + return updated_count + + @app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) def send_organizer_invitation(invitation_id, token): from django.conf import settings diff --git a/partner_programs/tests/test_solution_access_boundaries.py b/partner_programs/tests/test_solution_access_boundaries.py new file mode 100644 index 00000000..6fc77969 --- /dev/null +++ b/partner_programs/tests/test_solution_access_boundaries.py @@ -0,0 +1,79 @@ +import copy + +from django.test import TestCase + +from partner_programs.solution_models import ProgramSolutionMember +from partner_programs.tests.helpers import create_program_member, create_user +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates.solution_models import SolutionExpertAssignment +from project_rates.tests.helpers import create_rate_expert + + +class SolutionAccessBoundaryTests(SubmissionFixture, TestCase): + def setUp(self): + self.ready() + self.member = create_user() + create_program_member(self.program, user=self.member) + ProgramSolutionMember.objects.create(solution=self.solution, user=self.member) + self.expert = create_rate_expert(program=self.program) + self.outsider = create_user() + + def state(self): + self.solution.refresh_from_db() + return copy.deepcopy( + { + "name": self.solution.name, + "status": self.solution.status, + "version": self.solution.version, + "updated_at": self.solution.updated_at, + "submitted_at": self.solution.submitted_at, + "snapshot": self.solution.submission_snapshot, + "answers": list(self.solution.answers.order_by("pk").values()), + "members": list(self.solution.members.order_by("pk").values()), + } + ) + + def assert_denied(self, actor, status, code): + before = self.state() + command = self.command() + for method, url, body in ( + ( + "patch", + self.url, + { + **command, + "name": "Чужая правка", + "answers": [{"field_id": self.fields["number"]["id"], "value": 42}], + }, + ), + ("post", self.submit_url, command), + ): + with self.subTest(method=method, actor=actor.pk): + self.assert_error(call(actor, method, url, body), code, status) + self.assertEqual(self.state(), before) + + def test_draft_member_cannot_write_and_expert_and_outsider_cannot_discover_it(self): + for actor in (self.captain, self.member): + self.assertEqual(call(actor, "get", self.url).status_code, 200) + self.assert_denied(self.member, 403, "not_solution_captain") + for actor in (self.expert, self.outsider): + self.assert_error(call(actor, "get", self.url), "not_found", 404) + self.assert_denied(actor, 404, "not_found") + + def test_assigned_expert_can_read_submitted_but_cannot_write_or_submit(self): + self.assertEqual(self.submit().status_code, 200) + self.program.is_distributed_evaluation = True + self.program.save() + SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.expert.expert, + ) + for actor in (self.member, self.expert): + result = call(actor, "get", self.url) + self.assertEqual(result.status_code, 200, result.data) + self.assertFalse(result.data["permissions"]["can_edit"]) + self.assertFalse(result.data["permissions"]["can_submit"]) + self.assert_denied(actor, 403, "not_solution_captain") + self.assert_denied(self.outsider, 404, "not_found") diff --git a/partner_programs/tests/test_solution_administrative_history.py b/partner_programs/tests/test_solution_administrative_history.py new file mode 100644 index 00000000..3fedfc38 --- /dev/null +++ b/partner_programs/tests/test_solution_administrative_history.py @@ -0,0 +1,281 @@ +import copy +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models.deletion import ProtectedError +from django.test import TestCase, override_settings + +from partner_programs.models import PartnerProgram, PartnerProgramField +from partner_programs.services import solution_case_actions as actions +from partner_programs.solution_models import SolutionAdministrativeChange +from partner_programs.tests.helpers import create_user +from partner_programs.tests.test_solution_case_actions import CaseFixture, FAST_HASHERS +from partner_programs.tests.test_solution_forms import call +from project_rates.models import ExpertCaseScope +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionAdministrativeHistoryTests(CaseFixture, TestCase): + def setUp(self): + super().setUp() + self.history = ( + f"/programs/{self.program.pk}/management/solutions/" + f"{self.solution.pk}/history/" + ) + + def get_history( + self, path=None, actor=None, query=None, schema="SolutionAdminChangePage" + ): + return self.wire( + call(actor or self.manager, "get", path or self.history, query), schema=schema + ) + + def effects(self): + self.program.required_evaluators = 3 + self.program.save() + self.assertEqual(self.submit().status_code, 200) + self.rated = create_rate_expert(program=self.program) + self.pending = create_rate_expert(program=self.program) + self.compatible = create_rate_expert(program=self.program) + self.assignments = [] + for expert in [self.rated, self.pending, self.compatible]: + self.assignments.append( + SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=expert.expert, + ) + ) + for expert in [self.rated, self.pending]: + ExpertCaseScope.objects.create( + partner_program=self.program, + expert=expert.expert, + all_cases=False, + case_names=["Задача 1"], + ) + self.criterion = create_rate_criteria(self.program, min_value=0, max_value=10) + self.score = SolutionScore.objects.create( + solution=self.solution, criteria=self.criterion, user=self.rated, value="0" + ) + + def test_submitted_change_retains_original_materials_scores_and_rated_assignments( + self, + ): + self.effects() + before = self.saved() + original = copy.deepcopy(self.solution.submission_snapshot) + preview = self.wire(self.preview()) + self.assertTrue(preview["is_submitted"]) + self.assertEqual(preview["removed_assignments_count"], 1) + self.assertEqual(preview["score_count"], 1) + self.assertEqual(self.saved(), before) + self.assertEqual(SolutionAdministrativeChange.objects.count(), 0) + command = dict(case_name=self.target, preview_token=preview["preview_token"]) + receipt = self.wire(self.apply(command), schema="MutationReceipt") + self.solution.refresh_from_db() + expected = copy.deepcopy(original) + for answer in expected["answers"]: + if answer["field_id"] == preview["case_field_id"]: + answer["value"] = self.target + self.assertEqual(self.solution.submission_snapshot, expected) + self.assertEqual(self.solution.status, "submitted") + self.assertEqual( + self.solution.submitted_at, before["solution"][0]["submitted_at"] + ) + self.assertEqual( + receipt["solution_version"], before["solution"][0]["version"] + 1 + ) + self.assertEqual(self.saved()["scores"], before["scores"]) + self.assertEqual(self.saved()["members"], before["members"]) + self.assertEqual( + set(self.solution.expert_assignments.values_list("pk", flat=True)), + {self.assignments[0].pk, self.assignments[2].pk}, + ) + journal = self.solution.administrative_changes.get() + self.assertEqual(journal.before_snapshot, original) + self.assertEqual(journal.after_snapshot, expected) + self.assertEqual(journal.removed_assignment_ids, [self.assignments[1].pk]) + self.assertEqual(journal.actor, self.manager) + self.assertEqual(journal.before_version + 1, journal.after_version) + self.assertEqual(journal.case_name, self.target) + self.assertEqual( + self.wire(self.apply(command), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.solution.administrative_changes.count(), 1) + + def test_history_lists_and_reads_exact_saved_revisions_and_safe_references(self): + self.effects() + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + first = self.solution.administrative_changes.get() + first_snapshot = copy.deepcopy(first.after_snapshot) + self.wire(self.apply(self.command_case("Задача 1")), schema="MutationReceipt") + latest = self.solution.administrative_changes.first() + self.assertEqual(latest.before_snapshot, first_snapshot) + listing = self.get_history(query=dict(limit=1, offset=0)) + self.assertEqual(listing["count"], 2) + self.assertEqual(listing["results"][0]["id"], latest.pk) + self.assertTrue(listing["results"][0]["was_submitted"]) + old_page = self.get_history(query=dict(limit=1, offset=1)) + self.assertEqual(old_page["results"][0]["id"], first.pk) + self.assertEqual(self.get_history(query=dict(offset=50))["results"], []) + detail = self.get_history( + self.history + str(first.pk) + "/", schema="SolutionAdminChangeDetail" + ) + self.assertEqual(detail["before_snapshot"], first.before_snapshot) + self.assertEqual(detail["after_snapshot"], first.after_snapshot) + self.assertEqual(detail["change"]["application"]["solution_id"], self.solution.pk) + self.assertEqual(detail["change"]["application"]["program_id"], self.program.pk) + self.assertEqual(detail["change"]["actor"]["id"], self.manager.pk) + self.assertNotIn("email", detail["change"]["actor"]) + + def test_draft_case_change_also_records_history_without_artificial_submission(self): + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + change = self.solution.administrative_changes.get() + self.assertFalse(change.was_submitted) + self.assertIsNone(change.before_snapshot) + self.assertIsNone(change.after_snapshot) + self.assertEqual(change.previous_case, "Задача 1") + self.assertEqual(change.case_name, self.target) + detail = self.get_history( + self.history + str(change.pk) + "/", schema="SolutionAdminChangeDetail" + ) + self.assertIsNone(detail["before_snapshot"]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "draft") + + def test_ordinary_writers_remain_frozen_after_privileged_change(self): + self.assertEqual(self.submit().status_code, 200) + original = self.saved() + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + current = self.saved() + response = self.save_answers( + [{"field_id": self.fields["select"]["id"], "value": "Задача 1"}] + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "team_frozen") + self.solution.submission_snapshot = original["solution"][0]["submission_snapshot"] + with self.assertRaises(ValidationError), transaction.atomic(): + self.solution.save() + self.assertEqual(self.saved(), current) + + def test_journal_failure_rolls_back_case_snapshot_version_and_assignment_removal( + self, + ): + self.effects() + command = self.command_case() + before = self.saved() + with patch.object( + SolutionAdministrativeChange.objects, + "create", + side_effect=RuntimeError("journal unavailable"), + ): + with self.assertRaises(RuntimeError): + actions.apply(self.program.pk, self.solution.pk, self.manager, command) + self.assertEqual(self.saved(), before) + self.assertEqual(SolutionAdministrativeChange.objects.count(), 0) + + def test_changed_score_value_or_scope_invalidates_confirmation(self): + self.effects() + command = self.command_case() + self.score.value = "7" + self.score.save() + before = self.saved() + self.assertEqual( + self.wire(self.apply(command), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.saved(), before) + command = self.command_case() + ExpertCaseScope.objects.filter(expert=self.pending.expert).update(all_cases=True) + self.assertEqual( + self.wire(self.apply(command), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.wire(self.preview())["removed_assignments_count"], 0) + self.assertEqual(self.solution.administrative_changes.count(), 0) + + def test_history_is_private_and_has_no_write_route(self): + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + change = self.solution.administrative_changes.get() + detail = self.history + str(change.pk) + "/" + for actor in [ + None, + self.captain, + create_user(), + create_rate_expert(program=self.program), + ]: + status = 401 if actor is None else 403 + for path in [self.history, detail]: + self.wire(call(actor, "get", path), status) + for method in ["post", "patch", "delete"]: + self.assertEqual(call(self.manager, method, detail, {}).status_code, 405) + self.assertEqual(self.solution.administrative_changes.count(), 1) + + def test_history_rejects_foreign_change_unsafe_ids_and_unknown_queries(self): + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + change = self.solution.administrative_changes.get() + for suffix in [str(change.pk + 100), "0", str(2**53)]: + self.wire(call(self.manager, "get", self.history + suffix + "/"), 404) + for query in [dict(limit=0), dict(limit=101), dict(offset=-1), dict(all=True)]: + self.wire(call(self.manager, "get", self.history, query), 400) + self.wire( + call( + self.manager, "get", self.history + str(change.pk) + "/", dict(all=True) + ), + 400, + ) + foreign_path = self.history.replace( + f"/solutions/{self.solution.pk}/", f"/solutions/{self.solution.pk + 100}/" + ) + self.wire(call(self.manager, "get", foreign_path), 404) + + def test_history_does_not_depend_on_current_form_deadlines_or_creation_flag(self): + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + # Historical/configuration drift must not hide already recorded evidence. + # The ordinary template writer correctly forbids clearing it after answers. + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_template=None, solution_creation_enabled=False + ) + self.assertEqual(self.get_history()["count"], 1) + + def test_history_cannot_be_rewritten_or_deleted_by_model_or_admin(self): + from django.contrib import admin + from partner_programs.solution_admin import SolutionAdministrativeChangeAdmin + + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + change = self.solution.administrative_changes.get() + change.case_name = "Подмена истории" + with self.assertRaises(ValidationError): + change.save() + with self.assertRaises(ValidationError): + change.delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + SolutionAdministrativeChange.objects.filter(pk=change.pk).delete() + model_admin = SolutionAdministrativeChangeAdmin( + SolutionAdministrativeChange, admin.site + ) + self.assertFalse(model_admin.has_add_permission(None)) + self.assertFalse(model_admin.has_change_permission(None, change)) + self.assertFalse(model_admin.has_delete_permission(None, change)) + change.refresh_from_db() + self.assertEqual(change.case_name, self.target) + + def test_submitted_snapshot_uses_original_case_definition_after_current_form_changes( + self, + ): + self.assertEqual(self.submit().status_code, 200) + self.solution.refresh_from_db() + original = copy.deepcopy(self.solution.submission_snapshot) + PartnerProgramField.objects.filter(pk=self.fields["select"]["id"]).update( + label="Новая подпись", position=30 + ) + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot["form"], original["form"]) + self.assertEqual( + self.solution.submission_snapshot["members"], original["members"] + ) + self.assertEqual( + self.solution.administrative_changes.get().before_snapshot, original + ) diff --git a/partner_programs/tests/test_solution_answers.py b/partner_programs/tests/test_solution_answers.py new file mode 100644 index 00000000..4e7b0d84 --- /dev/null +++ b/partner_programs/tests/test_solution_answers.py @@ -0,0 +1,581 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +from pathlib import Path +from threading import Barrier +from unittest.mock import patch, Mock + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db import close_old_connections, transaction +from django.db.models.deletion import ProtectedError +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.utils import timezone +from rest_framework.test import APIClient + +from files.content import detect_content_type +from files.models import UserFile +from files.typings import FileInfo +from partner_programs import solution_answers, solution_forms +from partner_programs.solution_api import solution_data +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ProgramSolutionAnswer +from partner_programs.solution_schema import form_data +from partner_programs.tests.helpers import create_user +from partner_programs.tests.test_solution_forms import FormFixture, new_field, call + + +class AnswerFixture(FormFixture): + def seed_answers(self): + self.seed() + rows = [ + *self.payload()["fields"], + new_field(position=1, required=True, label="Описание"), + new_field("number", position=2), + new_field("checkbox", position=3), + new_field( + "file", + "presentation", + position=4, + validation=dict( + min_length=None, + max_length=None, + minimum=None, + maximum=None, + allowed_extensions=["pdf"], + max_file_size_bytes=20971520, + ), + ), + new_field("url", "repository", position=5), + ] + solution_forms.save_form( + program_id=self.program.pk, actor=self.manager, **self.payload(fields=rows) + ) + self.fields = { + field["type"]: field for field in form_data(self.program)["fields"] + } + self.url = f"/programs/{self.program.pk}/solutions/{self.solution.pk}/" + + def command(self, **changes): + self.solution.refresh_from_db() + return dict( + expected_version=self.solution.version, + expected_form_revision=form_data(self.program)["revision"], + **changes, + ) + + def save_answers(self, answers=None, actor=None, **changes): + if answers is not None: + changes["answers"] = answers + return call(actor or self.captain, "patch", self.url, self.command(**changes)) + + def upload( + self, + *, + user=None, + mime="application/pdf", + detected="application/pdf", + size=100, + link=None, + extension="pdf", + ): + index = UserFile.objects.count() + 1 + return UserFile.objects.create( + user=user or self.captain, + link=link or f"https://cdn.test/uploads/file-{index}.{extension}", + name=f"file-{index}", + extension=extension, + mime_type=mime, + detected_mime_type=detected, + size=size, + ) + + +class SolutionAnswerTests(AnswerFixture, TestCase): + def setUp(self): + self.seed_answers() + + def test_partial_save_incomplete_form_keeps_zero_false_and_omitted_values(self): + result = self.save_answers( + [ + {"field_id": self.fields["number"]["id"], "value": 0}, + {"field_id": self.fields["checkbox"]["id"], "value": False}, + ], + name="Моя заявка", + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data["version"], 2) + self.assertEqual(result.data["name"], "Моя заявка") + self.assertTrue(result.data["permissions"]["can_edit"]) + self.assertFalse(result.data["readiness"]["is_ready"]) + self.assertEqual(self.solution.answers.count(), 2) + saved = self.save_answers( + [{"field_id": self.fields["textarea"]["id"], "value": "Описание"}] + ) + self.assertEqual(saved.status_code, 200, saved.data) + self.assertEqual(self.solution.answers.count(), 3) + values = {answer.field_id: answer.value for answer in self.solution.answers.all()} + self.assertIs(values[self.fields["checkbox"]["id"]], False) + self.assertEqual(values[self.fields["number"]["id"]], 0) + + def test_response_matches_frozen_solution_schema(self): + response = self.save_answers( + [{"field_id": self.fields["textarea"]["id"], "value": "Текст"}] + ) + self.assertEqual(response.status_code, 200, response.data) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + from jsonschema import Draft202012Validator, FormatChecker + + Draft202012Validator( + {"components": spec["components"], "$ref": "#/components/schemas/Solution"}, + format_checker=FormatChecker(), + ).validate(response.data) + + def test_explicit_clear_deletes_only_requested_answers_and_not_file_binary(self): + upload = self.upload() + saved = self.save_answers( + [ + {"field_id": self.fields["textarea"]["id"], "value": "Текст"}, + {"field_id": self.fields["number"]["id"], "value": 0}, + {"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}, + ] + ) + self.assertEqual(saved.status_code, 200, saved.data) + cleared = self.save_answers( + [ + {"field_id": self.fields["textarea"]["id"], "value": " "}, + {"field_id": self.fields["file"]["id"], "value": None}, + ] + ) + self.assertEqual(cleared.status_code, 200, cleared.data) + self.assertEqual(self.solution.answers.count(), 1) + self.assertTrue(UserFile.objects.filter(pk=upload.pk).exists()) + + def test_types_foreign_fields_duplicates_and_constraints_fail_atomically(self): + foreign = self.program.__class__.objects.create( + name="Другая", + tag="other", + description="", + city="", + data_schema={}, + draft=False, + datetime_registration_ends=timezone.now(), + datetime_started=timezone.now(), + datetime_finished=timezone.now(), + ) + from partner_programs.models import PartnerProgramField + + other = PartnerProgramField.objects.create( + partner_program=foreign, name="other", label="Other", field_type="text" + ) + bad = [ + [{"field_id": self.fields["number"]["id"], "value": True}], + [{"field_id": self.fields["number"]["id"], "value": ""}], + [{"field_id": self.fields["checkbox"]["id"], "value": "false"}], + [ + { + "field_id": self.fields["file"]["id"], + "value": "https://external.test/a.pdf", + } + ], + [{"field_id": self.fields["select"]["id"], "value": "Другой кейс"}], + [{"field_id": self.fields["url"]["id"], "value": "javascript:alert(1)"}], + [{"field_id": other.pk, "value": "Чужое"}], + [ + {"field_id": self.fields["textarea"]["id"], "value": "A"}, + {"field_id": self.fields["textarea"]["id"], "value": "B"}, + ], + ] + for changes in bad: + with self.subTest(changes=changes): + result = self.save_answers(changes, name="Не сохранять") + self.assert_error(result, "invalid_answers", 400) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 1) + self.assertEqual(self.solution.name, "Новое решение") + self.assertEqual(self.solution.answers.count(), 0) + + def test_syntax_requires_both_versions_one_change_and_strict_answer_shape(self): + for body in ( + {"name": "X"}, + self.command(), + self.command(name=3), + self.command(name=""), + self.command(answers=[{"field_id": True, "value": 0}]), + self.command(answers=[{"field_id": 1, "value": [1, 2]}]), + self.command( + answers=[ + {"field_id": 1, "value": {"url": "x", "user_id": self.captain.pk}} + ] + ), + {**self.command(name="X"), "captain_id": self.captain.pk}, + ): + result = call(self.captain, "patch", self.url, body) + self.assert_error(result, "validation_error", 400) + self.assertEqual(self.solution.answers.count(), 0) + + def test_stale_versions_and_form_revision_never_overwrite(self): + body = self.command(name="Первое") + first = call(self.captain, "patch", self.url, body) + self.assertEqual(first.status_code, 200) + self.assert_error(call(self.captain, "patch", self.url, body), "version_conflict") + body = self.command(name="Второе") + body["expected_form_revision"] = "b" * 64 + self.assert_error( + call(self.captain, "patch", self.url, body), "form_revision_conflict" + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.name, "Первое") + + def test_only_captain_edits_and_foreign_solution_is_hidden(self): + from partner_programs.solution_models import ProgramSolutionMember + + member = create_user() + ProgramSolutionMember.objects.create(solution=self.solution, user=member) + for actor in (member, self.manager): + self.assert_error( + self.save_answers([], actor=actor), "not_solution_captain", 403 + ) + self.assert_error(self.save_answers([], actor=create_user()), "not_found", 404) + + def test_deadline_does_not_block_draft_save_but_mode_rollback_does(self): + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(days=1) + ) + self.program.save() + result = self.save_answers([], name="После срока") + self.assertEqual(result.status_code, 200, result.data) + self.assertFalse(result.data["submission"]["submission_open"]) + self.program.solution_storage_mode = "legacy_project" + self.program.save() + self.assert_error( + self.save_answers([], name="Не сохранять"), "solution_mode_unavailable" + ) + self.assertFalse( + call(self.captain, "get", self.url).data["permissions"]["can_edit"] + ) + + def freeze(self): + self.solution.refresh_from_db() + data = solution_data(self.solution, self.captain) + stamp = timezone.now() + self.solution.status = "submitted" + self.solution.submitted_at = stamp + self.solution.submission_snapshot = { + "name": data["name"], + "captain_id": data["captain_id"], + "members": data["team"]["members"], + "form": {**data["form"], "is_snapshot": True}, + "answers": data["answers"], + "submitted_at": stamp.isoformat(), + } + self.solution.save() + + def test_frozen_solution_rejects_edit_before_stale_version_and_keeps_snapshot(self): + self.freeze() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + command = self.command(name="Не сохранять") + command["expected_version"] = 999 + self.assert_error(call(self.captain, "patch", self.url, command), "team_frozen") + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + self.assertFalse( + call(self.captain, "get", self.url).data["permissions"]["can_edit"] + ) + + def test_file_registration_owner_type_and_size_are_rechecked(self): + valid = self.upload() + accepted = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": valid.link}}] + ) + self.assertEqual(accepted.status_code, 200, accepted.data) + for upload, code in ( + (self.upload(user=create_user()), "file_forbidden"), + (self.upload(detected="application/zip"), "file_type_mismatch"), + (self.upload(size=20971521), "file_too_large"), + ( + self.upload(extension="zip", detected="application/zip"), + "file_type_not_allowed", + ), + ): + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}] + ) + self.assert_error(result, "invalid_answers", 400) + self.assertEqual(result.data["errors"][0]["code"], code) + result = self.save_answers( + [ + { + "field_id": self.fields["file"]["id"], + "value": {"url": "https://external.test/no.pdf"}, + } + ] + ) + self.assert_error(result, "invalid_answers", 400) + + def test_already_owned_imported_file_can_move_between_fields_without_new_project( + self, + ): + source = self.upload(user=create_user()) + ProgramSolutionAnswer.objects.create( + solution=self.solution, + field_id=self.fields["file"]["id"], + value={"url": source.link}, + ) + result = solution_forms.save_form( + program_id=self.program.pk, + actor=self.manager, + **self.payload( + fields=[*self.payload()["fields"], new_field("file", position=10)] + ), + ) + target = result["form"]["fields"][-1] + response = self.save_answers( + [ + {"field_id": self.fields["file"]["id"], "value": None}, + {"field_id": target["id"], "value": {"url": source.link}}, + ] + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(self.solution.answers.count(), 1) + self.assertEqual(self.solution.answers.get().field_id, target["id"]) + + def test_upload_returns_old_url_shape_and_stores_content_type_from_actual_bytes(self): + from files.views import FileView + + def stored(file, user): + return FileInfo( + url="https://cdn.test/upload.pdf", + size=file.size, + name=file.name, + extension=file.extension, + mime_type=file.content_type, + detected_mime_type=file.detected_mime_type, + ) + + with patch.object(FileView.cdn.storage, "upload", side_effect=stored): + client = APIClient() + client.force_authenticate(self.captain) + response = client.post( + "/files/", + { + "file": SimpleUploadedFile( + "file.pdf", b"not a pdf", content_type="application/pdf" + ) + }, + format="multipart", + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(response.data, {"url": "https://cdn.test/upload.pdf"}) + upload = UserFile.objects.get(pk=response.data["url"]) + self.assertEqual(upload.detected_mime_type, "text/plain") + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": response.data}] + ) + self.assert_error(result, "invalid_answers", 400) + + def test_original_image_preserves_bytes_and_default_upload_still_converts(self): + from PIL import Image + from files.views import FileView + + image = BytesIO() + Image.new("RGB", (4, 4), "purple").save(image, format="PNG") + original = image.getvalue() + stored_bytes = [] + + def stored(file, user): + stored_bytes.append( + file.buffer.read() if hasattr(file.buffer, "read") else bytes(file.buffer) + ) + return FileInfo( + url=f"https://cdn.test/image-{len(stored_bytes)}.{file.extension}", + size=file.size, + name=file.name, + extension=file.extension, + mime_type=file.content_type, + detected_mime_type=file.detected_mime_type, + ) + + client = APIClient() + client.force_authenticate(self.captain) + with patch.object(FileView.cdn.storage, "upload", side_effect=stored): + kept = client.post( + "/files/?preserve_original=true", + { + "file": SimpleUploadedFile( + "photo.png", original, content_type="image/png" + ) + }, + format="multipart", + ) + converted = client.post( + "/files/", + { + "file": SimpleUploadedFile( + "photo.png", original, content_type="image/png" + ) + }, + format="multipart", + ) + self.assertEqual(kept.status_code, 201, kept.data) + self.assertEqual(kept.data, {"url": "https://cdn.test/image-1.png"}) + self.assertEqual(converted.data, {"url": "https://cdn.test/image-2.webp"}) + self.assertEqual(stored_bytes[0], original) + self.assertNotEqual(stored_bytes[1], original) + self.assertEqual( + UserFile.objects.get(pk=kept.data["url"]).detected_mime_type, "image/png" + ) + self.assertEqual( + UserFile.objects.get(pk=converted.data["url"]).detected_mime_type, + "image/webp", + ) + + @override_settings(SELECTEL_SWIFT_URL="https://cdn.test/uploads/") + def test_old_upload_is_verified_only_inside_owned_cdn_without_redirects(self): + old = self.upload(detected="") + response = Mock(status_code=200) + response.iter_content.return_value = [b"%PDF-1.7\nfile\n%%EOF"] + with patch( + "partner_programs.solution_files.requests.get", return_value=response + ) as get: + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": old.link}}] + ) + self.assertEqual(result.status_code, 200, result.data) + get.assert_called_once_with( + old.link, stream=True, timeout=(5, 10), allow_redirects=False + ) + old.refresh_from_db() + self.assertEqual(old.detected_mime_type, "application/pdf") + external = self.upload(detected="", link="http://127.0.0.1/private.pdf") + with patch("partner_programs.solution_files.requests.get") as get: + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": external.link}}] + ) + self.assert_error(result, "invalid_answers", 400) + get.assert_not_called() + + def test_submitted_file_deletion_is_blocked_before_cdn_and_through_orm(self): + from files.views import FileView + + upload = self.upload() + self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}] + ) + self.freeze() + client = APIClient() + client.force_authenticate(self.captain) + with patch.object(FileView.cdn, "delete") as cdn: + response = client.delete("/files/", QUERY_STRING="link=" + upload.link) + self.assert_error(response, "file_in_use") + cdn.assert_not_called() + with self.assertRaises(ProtectedError), transaction.atomic(): + UserFile.objects.filter(pk=upload.pk).delete() + self.assertTrue(UserFile.objects.filter(pk=upload.pk).exists()) + + def test_draft_file_deletion_keeps_existing_semantics(self): + from files.views import FileView + + upload = self.upload() + client = APIClient() + client.force_authenticate(self.captain) + with patch.object(FileView.cdn, "delete") as cdn: + response = client.delete("/files/", QUERY_STRING="link=" + upload.link) + self.assertEqual(response.status_code, 204) + cdn.assert_called_once_with(upload.link) + + def test_snapshot_reference_alone_protects_admin_bulk_delete_before_any_cdn_call( + self, + ): + from django.contrib.admin.sites import AdminSite + from django.core.exceptions import PermissionDenied + from files.admin import UserFileAdmin + + protected = self.upload() + unused = self.upload() + self.freeze() + self.solution.submission_snapshot["answers"] = [ + {"field_id": self.fields["file"]["id"], "value": {"url": protected.link}} + ] + # Deliberately emulate historical import data outside supported model writes. + type(self.solution).objects.filter(pk=self.solution.pk).update( + submission_snapshot=self.solution.submission_snapshot + ) + self.assertFalse(self.solution.answers.exists()) + admin = UserFileAdmin(UserFile, AdminSite()) + with patch.object(admin.cdn, "delete") as cdn: + with self.assertRaises(PermissionDenied), transaction.atomic(): + admin.delete_queryset( + Mock(), UserFile.objects.filter(pk__in=[protected.pk, unused.pk]) + ) + cdn.assert_not_called() + self.assertTrue(UserFile.objects.filter(pk=protected.pk).exists()) + self.assertTrue(UserFile.objects.filter(pk=unused.pk).exists()) + + @override_settings(SELECTEL_SWIFT_URL="https://cdn.test/uploads/") + def test_legacy_redirect_is_rejected_without_binding_or_following(self): + old = self.upload(detected="") + response = Mock(status_code=302) + with patch( + "partner_programs.solution_files.requests.get", return_value=response + ) as get: + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": old.link}}] + ) + self.assert_error(result, "invalid_answers", 400) + self.assertEqual(result.data["errors"][0]["code"], "file_unavailable") + self.assertFalse(self.solution.answers.exists()) + self.assertFalse(get.call_args.kwargs["allow_redirects"]) + response.close.assert_called_once() + + def test_byte_inspection_does_not_consume_stream_or_trust_filename(self): + stream = BytesIO(b"%PDF-1.7\n") + self.assertEqual(detect_content_type(stream), "application/pdf") + self.assertEqual(stream.tell(), 0) + import zipfile + + archive = BytesIO() + with zipfile.ZipFile(archive, "w") as zipped: + zipped.writestr("readme.txt", "test") + archive.seek(0) + self.assertEqual(detect_content_type(archive), "application/zip") + self.assertFalse(archive.closed) + self.assertEqual(archive.tell(), 0) + + +class SolutionAnswerConcurrencyTests(AnswerFixture, TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_two_patches_with_same_version_serialize(self): + self.seed_answers() + command = self.command(name="Название") + barrier = Barrier(2) + + def write(label): + close_old_connections() + try: + barrier.wait(timeout=10) + solution_answers.update_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **{**command, "name": label}, + ) + return "saved" + except SolutionError as error: + return error.payload["code"] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(write, ["Первое", "Второе"])) + self.assertCountEqual(results, ["saved", "version_conflict"]) diff --git a/partner_programs/tests/test_solution_archive_actions.py b/partner_programs/tests/test_solution_archive_actions.py new file mode 100644 index 00000000..1d509218 --- /dev/null +++ b/partner_programs/tests/test_solution_archive_actions.py @@ -0,0 +1,634 @@ +import copy +from time import time +from unittest.mock import patch + +from django.core import signing +from django.core.exceptions import ValidationError +from django.db import connection, transaction +from django.db.models.deletion import ProtectedError +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from files.models import UserFile +from files.views import FileView +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.services import solution_archive_actions as actions +from partner_programs.services.solution_analytics import SolutionAnalyticsContext +from partner_programs.solution_api import create_solution +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + SolutionAdministrativeChange, + SolutionInvitation, +) +from partner_programs.solution_participants import participant_profiles +from partner_programs.solution_submission import submit_solution +from partner_programs.tests.helpers import ( + create_user, + create_program_member, + create_partner_program, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_member_actions import ( + MemberFixture, + FAST_HASHERS, +) +from project_rates.solution_allocation import assignment_snapshot +from project_rates.solution_assignments import enable_workspace +from projects.models import Project + + +class ArchiveFixture(MemberFixture): + def setUp(self): + super().setUp() + self.archive_root = f"/programs/{self.program.pk}/management/solutions/archive/" + + def archive_preview(self, ids=None, actor=None): + return call( + actor or self.manager, + "post", + self.archive_root + "preview/", + dict(ids=[self.solution.pk] if ids is None else ids), + ) + + def archive_command(self, ids=None): + ids = [self.solution.pk] if ids is None else ids + response = self.archive_preview(ids) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["can_apply"], response.data) + return dict(ids=ids, preview_token=response.data["preview_token"]) + + def archive_apply(self, command, actor=None): + response = call( + actor or self.manager, "post", self.archive_root + "apply/", command + ) + self.solution.refresh_from_db() + return response + + def wire(self, response, status=200, schema="SolutionArchivePreview"): + return super().wire(response, status, schema) + + def retained(self): + self.solution.refresh_from_db() + return dict( + solution=ProgramSolution.all_objects.filter(pk=self.solution.pk) + .values() + .get(), + members=list(self.solution.members.order_by("pk").values()), + answers=list(self.solution.answers.order_by("pk").values()), + invitations=list(self.solution.invitations.order_by("pk").values()), + assignments=list(self.solution.expert_assignments.order_by("pk").values()), + scores=list(self.solution.scores.order_by("pk").values()), + registrations=list( + PartnerProgramUserProfile.objects.filter(partner_program=self.program) + .order_by("pk") + .values() + ), + projects=list(Project.objects.order_by("pk").values()), + ) + + def second(self): + user = create_user() + create_program_member(self.program, user=user) + return create_solution(self.program.pk, user, {"name": "Второй черновик"})[0] + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionArchiveActionTests(ArchiveFixture, TestCase): + def test_preview_is_read_only_and_counts_scored_assignment_and_pending(self): + pending = SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user(), role="Дизайнер" + ) + self.evaluation() + before = self.retained() + result = self.wire(self.archive_preview()) + self.assertEqual(result["score_count"], 1) + self.assertEqual(result["removed_assignments_count"], 1) + self.assertEqual(result["revoked_invitation_count"], 1) + self.assertTrue(result["items"][0]["is_submitted"]) + proof = signing.loads(result["preview_token"], salt=actions.SALT) + self.assertEqual( + (proof["actor"], proof["program"], proof["ids"]), + (self.manager.pk, self.program.pk, [self.solution.pk]), + ) + self.assertEqual(self.retained(), before) + self.assertTrue(SolutionInvitation.objects.filter(pk=pending.pk).exists()) + + def test_submitted_archive_keeps_work_original_submission_scores_roster_and_registration( + self, + ): + pending = SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user(), role="Дизайнер" + ) + self.evaluation() + before = self.retained() + original = copy.deepcopy(self.solution.submission_snapshot) + with self.assertRaises(ValidationError), transaction.atomic(): + self.solution.expert_assignments.all().delete() + response = self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + after = self.retained() + self.assertEqual(response["changed_count"], 1) + self.assertEqual(self.solution.submission_snapshot, original) + self.assertEqual(self.solution.status, "submitted") + self.assertEqual(self.solution.submitted_at, before["solution"]["submitted_at"]) + self.assertEqual(self.solution.archive_reason, "detached") + self.assertEqual(self.solution.archived_by_id, self.manager.pk) + self.assertEqual(self.solution.version, before["solution"]["version"] + 1) + for key in ("members", "answers", "scores", "registrations", "projects"): + self.assertEqual(after[key], before[key]) + self.assertFalse(self.solution.expert_assignments.exists()) + self.assertFalse(SolutionInvitation.objects.filter(pk=pending.pk).exists()) + self.assertTrue( + SolutionInvitation.objects.filter( + pk=self.invitation.pk, status="accepted" + ).exists() + ) + self.assertFalse(ProgramSolution.objects.filter(pk=self.solution.pk).exists()) + self.assertTrue(ProgramSolution.all_objects.filter(pk=self.solution.pk).exists()) + change = self.solution.administrative_changes.get() + self.assertEqual(change.before_snapshot, original) + self.assertEqual(change.after_snapshot, original) + self.assertEqual(change.archive_snapshot, self.solution.archive_snapshot) + self.assertEqual(change.removed_assignment_ids, [self.assignment.pk]) + self.assertEqual(change.revoked_invitation_ids, [pending.pk]) + + def test_draft_archive_has_its_own_material_snapshot_without_submission(self): + original = call(self.captain, "get", self.url).data + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + saved = self.retained() + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submitted_at) + self.assertIsNone(self.solution.submission_snapshot) + self.assertEqual(self.solution.archive_snapshot["answers"], original["answers"]) + self.program.fields.update(label="Новая текущая форма") + current = call(self.captain, "get", self.url) + self.assertEqual(current.status_code, 200, current.data) + self.assertTrue(current.data["form"]["is_snapshot"]) + self.assertEqual(current.data["form"]["fields"], original["form"]["fields"]) + self.assertFalse(any(current.data["permissions"].values())) + self.assertTrue(current.data["team"]["is_frozen"]) + self.assertEqual(current.data["participation"]["status"], "archived") + self.assertEqual(current.data["answers"], original["answers"]) + self.assertEqual(self.retained(), saved) + + def test_noncompetitive_archive_remains_a_saved_draft(self): + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=False) + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + response = call(self.captain, "get", self.url) + self.assertFalse(response.data["submission"]["required"]) + self.assertEqual(response.data["submission"]["status"], "draft") + self.assertIsNone(response.data["submission"]["submitted_at"]) + self.assertIsNone(response.data["readiness"]) + + def test_archive_is_owned_read_only_and_excluded_from_default_lists_and_expert_access( + self, + ): + self.evaluation() + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + for actor in [self.captain, self.member, self.manager]: + detail = call(actor, "get", self.url) + self.assertEqual(detail.status_code, 200, detail.data) + self.assertFalse(any(detail.data["permissions"].values())) + self.assertEqual( + call(actor, "get", f"/programs/{self.program.pk}/solutions/").data[ + "count" + ], + 0, + ) + self.assertEqual( + call( + actor, + "get", + f"/programs/{self.program.pk}/solutions/?participation=archived", + ).data["count"], + 1, + ) + self.assertEqual( + call( + actor, + "get", + f"/programs/{self.program.pk}/solutions/?participation=all", + ).data["count"], + 1, + ) + for actor in [self.expert, create_user()]: + self.assertEqual(call(actor, "get", self.url).status_code, 404) + self.assertEqual( + call(self.expert, "get", self.url + "evaluation/").status_code, 404 + ) + self.assertEqual(call(self.member, "get", self.history).status_code, 403) + detail = self.wire( + call( + self.manager, + "get", + self.history + str(self.solution.administrative_changes.get().pk) + "/", + ), + schema="SolutionAdminChangeDetail", + ) + self.assertEqual(detail["change"]["action"], "archive") + self.assertEqual(detail["archive_snapshot"], self.solution.archive_snapshot) + + def test_archived_team_does_not_reserve_active_membership_or_captain_uniqueness(self): + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + ctx = call(self.captain, "get", f"/programs/{self.program.pk}/solution-context/") + self.assertIsNone(ctx.data["current_application"]) + self.assertTrue(ctx.data["can_create"]) + for row in participant_profiles(self.program.pk): + self.assertFalse(row.is_project_leader or row.is_project_collaborator) + created = call( + self.captain, + "post", + f"/programs/{self.program.pk}/solutions/", + dict(name="Новая заявка"), + ) + self.assertEqual(created.status_code, 201, created.data) + self.assertNotEqual(created.data["id"], self.solution.pk) + root = f'/programs/{self.program.pk}/solutions/{created.data["id"]}/' + invited = call( + self.captain, + "post", + root + "invitations/", + dict( + expected_solution_version=created.data["version"], + recipient_user_id=self.member.pk, + role="Аналитик", + ), + ) + self.assertEqual(invited.status_code, 201, invited.data) + accepted = call( + self.member, + "post", + f'/solution-invitations/{invited.data["invitation"]["id"]}/accept/', + dict(expected_version=invited.data["invitation"]["version"]), + ) + self.assertEqual(accepted.status_code, 200, accepted.data) + self.assertEqual(ProgramSolution.objects.filter(program=self.program).count(), 1) + self.assertEqual( + ProgramSolution.all_objects.filter(program=self.program).count(), 2 + ) + self.assertTrue( + ProgramSolution.all_objects.get(pk=self.solution.pk).archive_snapshot + ) + + def test_creation_flag_deadline_and_form_still_control_new_application(self): + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False, + datetime_project_submission_ends=timezone.now() - timezone.timedelta(days=1), + ) + response = call( + self.captain, "post", f"/programs/{self.program.pk}/solutions/", {} + ) + self.assertEqual(response.data["code"], "solution_creation_disabled") + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=True + ) + response = call( + self.captain, "post", f"/programs/{self.program.pk}/solutions/", {} + ) + self.assertEqual(response.data["code"], "submission_closed") + self.assertEqual(call(self.captain, "get", self.url).status_code, 200) + + def test_archive_is_allowed_after_deadline_without_current_form_or_creation_flag( + self, + ): + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False, + solution_template=None, + datetime_project_submission_ends=timezone.now() - timezone.timedelta(days=1), + ) + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + self.assertEqual(call(self.captain, "get", self.url).status_code, 200) + + def test_archived_materials_are_frozen_through_api_model_and_deletion(self): + self.evaluation() + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + before = self.retained() + command = self.command(name="Попытка изменить архив") + response = call(self.captain, "patch", self.url, command) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "solution_archived") + for obj in [self.solution, self.membership, self.solution.answers.first()]: + with self.assertRaises(ValidationError), transaction.atomic(): + obj.save() + with self.assertRaises(ProtectedError), transaction.atomic(): + obj.delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + ProgramSolution.all_objects.filter(pk=self.solution.pk).delete() + self.score.value = "9" + with self.assertRaises(ValidationError), transaction.atomic(): + self.score.save() + self.assertEqual(self.retained(), before) + + def test_archived_draft_file_is_protected_before_cdn_and_through_orm(self): + upload = UserFile.objects.create( + link="https://example.com/archive.pdf", + user=self.captain, + name="Материалы", + extension="pdf", + mime_type="application/pdf", + detected_mime_type="application/pdf", + size=10, + ) + ProgramSolutionAnswer.objects.create( + solution=self.solution, + field_id=self.fields["file"]["id"], + value={"url": upload.link}, + ) + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + client = APIClient() + client.force_authenticate(self.captain) + with patch.object(FileView.cdn, "delete") as cdn: + response = client.delete("/files/", QUERY_STRING="link=" + upload.link) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "file_in_use") + cdn.assert_not_called() + with self.assertRaises(ProtectedError), transaction.atomic(): + UserFile.objects.filter(pk=upload.pk).delete() + self.assertTrue(UserFile.objects.filter(pk=upload.pk).exists()) + + def test_journal_failure_rolls_back_archive_pending_assignments_and_version(self): + SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user(), role="Дизайнер" + ) + self.evaluation() + command = self.archive_command() + before = self.retained() + with patch.object( + SolutionAdministrativeChange.objects, + "create", + side_effect=RuntimeError("journal unavailable"), + ): + with self.assertRaises(RuntimeError): + actions.apply(self.program.pk, self.manager, command) + self.assertEqual(self.retained(), before) + self.assertTrue(ProgramSolution.objects.filter(pk=self.solution.pk).exists()) + self.assertEqual(self.solution.administrative_changes.count(), 0) + + def test_bulk_archive_is_atomic_when_one_solution_changed_or_already_archived(self): + other = self.second() + command = self.archive_command([other.pk, self.solution.pk]) + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + before = self.retained() + self.wire(self.archive_apply(command), 409) + self.assertEqual(self.retained(), before) + self.assertTrue(ProgramSolution.objects.filter(pk=other.pk).exists()) + preview = self.wire(self.archive_preview([other.pk, self.solution.pk])) + self.assertFalse(preview["can_apply"]) + self.assertIsNone(preview["preview_token"]) + + def test_bulk_mixed_draft_and_submitted_are_archived_once_each(self): + self.evaluation() + other = self.second() + result = self.wire( + self.archive_apply(self.archive_command([other.pk, self.solution.pk])), + schema="SolutionArchiveReceipt", + ) + self.assertEqual(result["changed_count"], 2) + self.assertEqual( + {r["application"]["solution_id"] for r in result["items"]}, + {other.pk, self.solution.pk}, + ) + self.assertFalse(ProgramSolution.objects.filter(program=self.program).exists()) + self.assertEqual( + SolutionAdministrativeChange.objects.filter( + action="archive", solution__program=self.program + ).count(), + 2, + ) + + def test_replay_expiry_actor_and_selection_binding_do_not_repeat_changes(self): + other = self.second() + command = self.archive_command() + before = self.retained() + for token in [ + "bad", + signing.dumps([], salt=actions.SALT), + signing.dumps({}, salt="solution-case-actions-v1"), + ]: + self.wire(self.archive_apply({**command, "preview_token": token}), 409) + with patch("django.core.signing.time.time", return_value=time() - 601): + expired = self.archive_command() + self.wire(self.archive_apply(expired), 409) + self.wire(self.archive_apply(command, actor=create_user(is_staff=True)), 409) + self.wire(self.archive_apply({**command, "ids": [other.pk]}), 409) + self.assertEqual(self.retained(), before) + self.wire(self.archive_apply(command), schema="SolutionArchiveReceipt") + after = self.retained() + self.wire(self.archive_apply(command), 409) + self.assertEqual(self.retained(), after) + self.assertEqual(self.solution.administrative_changes.count(), 1) + + def test_permissions_legacy_scope_and_strict_selection_are_preserved(self): + for actor, status in [ + (None, 401), + (self.captain, 403), + (self.member, 403), + (create_user(), 403), + ]: + self.wire( + call( + actor, + "post", + self.archive_root + "preview/", + dict(ids=[self.solution.pk]), + ), + status, + ) + for body in [ + {}, + {"ids": []}, + {"ids": [True]}, + {"ids": ["1"]}, + {"ids": [0]}, + {"ids": [1.5]}, + {"ids": [2**53]}, + {"ids": [self.solution.pk] * 2}, + {"ids": [self.solution.pk], "project_id": 1}, + ]: + self.wire( + call(self.manager, "post", self.archive_root + "preview/", body), 400 + ) + self.wire( + call( + self.manager, + "post", + self.archive_root + "preview/?all=true", + dict(ids=[self.solution.pk]), + ), + 400, + ) + self.wire(self.archive_preview([2**53 - 1]), 404) + other = create_partner_program(solution_storage_mode="solution") + self.wire( + call( + create_user(is_staff=True), + "post", + f"/programs/{other.pk}/management/solutions/archive/preview/", + dict(ids=[self.solution.pk]), + ), + 404, + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="legacy_project" + ) + self.assertEqual( + self.wire(self.archive_preview(), 409)["code"], "solution_mode_unavailable" + ) + + def test_active_analytics_results_allocation_and_publication_exclude_archived_work( + self, + ): + self.evaluation() + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + self.solution.refresh_from_db() + self.program.refresh_from_db() + self.assertFalse(SolutionAnalyticsContext(self.program).solutions) + self.assertEqual(assignment_snapshot(self.program), []) + self.program.required_evaluators = None + self.program.save() + enable_workspace(self.program) + self.assertFalse(self.solution.expert_assignments.exists()) + self.assertTrue(self.solution.scores.exists()) + + def test_archived_noncompetitive_publication_is_hidden_and_marker_retained(self): + from partner_programs.services.publishing import publish_solution_program + + now = timezone.now() + PartnerProgram.objects.filter(pk=self.program.pk).update( + is_competitive=False, + publish_projects_after_finish=True, + datetime_finished=now - timezone.timedelta(days=1), + ) + self.assertEqual(publish_solution_program(self.program.pk, now=now), 1) + public = f"/programs/{self.program.pk}/published-solutions/" + self.assertEqual(call(None, "get", public).data["count"], 1) + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, now) + self.assertEqual(call(None, "get", public).data["count"], 0) + self.assertEqual( + call(None, "get", public + str(self.solution.pk) + "/").status_code, 404 + ) + self.assertEqual( + publish_solution_program( + self.program.pk, now=now + timezone.timedelta(days=1) + ), + 0, + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionArchiveConcurrencyTests(ArchiveFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL locks") + super().setUp() + + def race(self, commands): + from partner_programs.tests.test_solution_case_actions import ( + SolutionCaseActionConcurrencyTests, + ) + + return SolutionCaseActionConcurrencyTests.race(self, commands) + + def test_two_archival_confirmations_have_one_revision(self): + self.evaluation() + command = self.archive_command() + before = self.retained() + + def archive(): + return actions.apply(self.program.pk, self.manager, command) + + self.assertCountEqual( + self.race([archive, archive]), ["saved", "solution_action_conflict"] + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, before["solution"]["version"] + 1) + self.assertEqual(self.solution.administrative_changes.count(), 1) + self.assertEqual( + list(self.solution.scores.order_by("pk").values()), before["scores"] + ) + + def test_archive_and_first_submission_leave_a_coherent_saved_work(self): + command = self.archive_command() + submit = self.command() + version = self.solution.version + + def archive(): + return actions.apply(self.program.pk, self.manager, command) + + def send(): + return submit_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **submit, + ) + + result = self.race([archive, send]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, version + 1) + if result[0] == "saved": + self.assertEqual(result, ["saved", "solution_archived"]) + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submission_snapshot) + self.assertEqual(len(self.solution.archive_snapshot["members"]), 2) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertIsNone(self.solution.archived_at) + self.assertEqual(self.solution.status, "submitted") + + def test_archive_and_score_update_preserve_the_committed_score(self): + self.evaluation() + command = self.archive_command() + original = self.score.value + + def archive(): + return actions.apply(self.program.pk, self.manager, command) + + def score(): + self.score.value = "7" + try: + self.score.save() + except ValidationError: + from partner_programs.solution_errors import SolutionError + + raise SolutionError("solution_archived", "Решение снято с участия.") + + result = self.race([archive, score]) + self.score.refresh_from_db() + self.solution.refresh_from_db() + if result[0] == "saved": + self.assertEqual(result, ["saved", "solution_archived"]) + self.assertEqual(self.score.value, original) + self.assertIsNotNone(self.solution.archived_at) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertEqual(self.score.value, "7") + self.assertIsNone(self.solution.archived_at) diff --git a/partner_programs/tests/test_solution_case_actions.py b/partner_programs/tests/test_solution_case_actions.py new file mode 100644 index 00000000..b34fcc30 --- /dev/null +++ b/partner_programs/tests/test_solution_case_actions.py @@ -0,0 +1,606 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from queue import Queue +from threading import Barrier +from time import monotonic, sleep, time +from unittest.mock import patch + +from django.core import signing +from django.db import close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from jsonschema import Draft202012Validator + +from partner_programs import solution_answers +from partner_programs.models import PartnerProgram, PartnerProgramField +from partner_programs.services import solution_case_actions as actions +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_submission import submit_solution +from partner_programs.solution_models import ProgramSolution +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert +from projects.models import Project + +FAST_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +class CaseFixture(SubmissionFixture): + def setUp(self): + self.ready() + self.root = ( + f"/programs/{self.program.pk}/management/solutions/{self.solution.pk}/case/" + ) + self.target = "Задача 2" + PartnerProgramField.objects.filter(pk=self.fields["select"]["id"]).update( + solution_options=["Задача 1", self.target], options="Задача 1|" + self.target + ) + + def preview(self, actor=None, case=None): + return call( + actor or self.manager, + "post", + self.root + "preview/", + {"case_name": case or self.target}, + ) + + def command_case(self, case=None): + selected = case or self.target + result = self.preview(case=selected) + self.assertEqual(result.status_code, 200, result.data) + self.assertTrue(result.data["can_apply"], result.data) + return dict(case_name=selected, preview_token=result.data["preview_token"]) + + def apply(self, command, actor=None): + return call(actor or self.manager, "post", self.root + "apply/", command) + + def saved(self): + self.solution.refresh_from_db() + return dict( + solution=list(ProgramSolution.objects.filter(pk=self.solution.pk).values()), + answers=list(self.solution.answers.order_by("pk").values()), + members=list(self.solution.members.order_by("pk").values()), + assignments=list(self.solution.expert_assignments.order_by("pk").values()), + scores=list(self.solution.scores.order_by("pk").values()), + projects=Project.objects.count(), + ) + + def wire(self, response, status=200, schema="SolutionCaseChangePreview"): + self.assertEqual(response.status_code, status, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + { + "components": spec["components"], + "$ref": "#/components/schemas/" + (schema if status == 200 else "Error"), + } + ).validate(response.data) + return response.data + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCaseActionTests(CaseFixture, TestCase): + def test_preview_is_read_only_and_has_real_ref_case_field_and_version(self): + before = self.saved() + result = self.wire(self.preview()) + self.assertEqual( + result["application"], + dict( + kind="solution", + program_id=self.program.pk, + solution_id=self.solution.pk, + name=self.solution.name, + ), + ) + self.assertEqual(result["case_field_id"], self.fields["select"]["id"]) + self.assertEqual(result["current_case"], "Задача 1") + self.assertEqual(result["case_name"], "Задача 2") + self.assertEqual(result["solution_version"], self.solution.version) + self.assertEqual(result["errors"], []) + proof = signing.loads(result["preview_token"], salt=actions.SALT) + self.assertEqual(set(proof), {"actor", "program", "solution", "case", "state"}) + self.assertEqual(proof["solution"], self.solution.pk) + self.assertEqual(proof["program"], self.program.pk) + self.assertEqual(proof["actor"], self.manager.pk) + self.assertEqual(proof["case"], self.target) + self.assertEqual(self.saved(), before) + + def test_apply_changes_only_case_and_version_time_without_touching_zero_false_or_team( + self, + ): + command = self.command_case() + before = self.saved() + result = self.wire(self.apply(command), schema="MutationReceipt") + after = self.saved() + self.assertEqual(result["solution_id"], self.solution.pk) + self.assertEqual(result["solution_version"], before["solution"][0]["version"] + 1) + self.assertEqual(after["members"], before["members"]) + self.assertEqual(after["projects"], before["projects"]) + self.assertEqual(after["assignments"], []) + for key, value in before["solution"][0].items(): + if key not in ("version", "updated_at"): + self.assertEqual(after["solution"][0][key], value) + for row in before["answers"]: + expected = ( + {**row, "value": self.target} + if row["field_id"] == self.fields["select"]["id"] + else row + ) + self.assertIn(expected, after["answers"]) + values = {a["field_id"]: a["value"] for a in after["answers"]} + self.assertEqual(values[self.fields["number"]["id"]], 0) + self.assertIs(values[self.fields["checkbox"]["id"]], False) + + def test_missing_case_and_exact_spaces_pipe_choice_are_supported(self): + self.solution.answers.filter(field_id=self.fields["select"]["id"]).delete() + target = " Город | Образование " + PartnerProgramField.objects.filter(pk=self.fields["select"]["id"]).update( + solution_options=["Задача 1", self.target, target] + ) + response = self.wire(self.preview(case=target)) + self.assertIsNone(response["current_case"]) + self.wire( + self.apply(dict(case_name=target, preview_token=response["preview_token"])), + schema="MutationReceipt", + ) + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, target + ) + + def test_same_case_has_no_confirmation_and_no_write(self): + before = self.saved() + response = self.wire(self.preview(case="Задача 1")) + self.assertFalse(response["can_apply"]) + self.assertIsNone(response["preview_token"]) + self.assertEqual(response["errors"][0]["code"], "case_unchanged") + self.assertEqual(self.saved(), before) + + def test_manager_staff_scoping_and_foreign_solution_ids(self): + member = create_user() + create_program_member(self.program, user=member) + expert = create_rate_expert() + expert.expert.programs.add(self.program) + other_program = create_partner_program(solution_storage_mode="solution") + foreign = create_user() + other_program.managers.add(foreign) + for actor in (self.captain, member, expert, foreign): + with self.subTest(actor=actor.pk): + self.assertEqual( + self.wire(self.preview(actor=actor), 403)["code"], "permission_denied" + ) + self.wire(self.preview(actor=create_user(is_staff=True))) + obj = ProgramSolution.objects.create(program=other_program, captain=foreign) + response = call( + self.manager, + "post", + f"/programs/{self.program.pk}/management/solutions/{obj.pk}/case/preview/", + {"case_name": self.target}, + ) + self.assertEqual(self.wire(response, 404)["code"], "not_found") + self.assertEqual( + self.wire( + call(None, "post", self.root + "preview/", {"case_name": self.target}), + 401, + )["code"], + "authentication_required", + ) + + def test_strict_case_and_unknown_input_query_safe_ids(self): + before = self.saved() + for data in ( + {}, + {"case_name": ""}, + {"case_name": " "}, + {"case_name": 12}, + {"case_name": True}, + {"case_name": []}, + {"case_name": "a" * 1001}, + {"case_name": self.target, "ids": [self.solution.pk]}, + {"case_name": self.target, "action": "change_case"}, + ): + with self.subTest(data=data): + self.wire(call(self.manager, "post", self.root + "preview/", data), 400) + self.wire( + call( + self.manager, + "post", + self.root + "preview/?unknown=1", + {"case_name": self.target}, + ), + 400, + ) + for value in (0, 9007199254740992): + self.wire( + call( + self.manager, + "post", + f"/programs/{self.program.pk}/management/solutions/{value}/case/preview/", + {"case_name": self.target}, + ), + 404, + ) + self.assertEqual(self.saved(), before) + + def test_invalid_option_returns_field_issue_and_does_not_write(self): + before = self.saved() + error = self.wire(self.preview(case="Неизвестный кейс"), 400) + self.assertEqual(error["code"], "invalid_answers") + self.assertEqual(error["errors"][0]["field_id"], self.fields["select"]["id"]) + self.assertEqual(self.saved(), before) + + def test_confirmation_requires_a_string_token_before_any_write(self): + before = self.saved() + for token in (0, True, [], {}, None, "", "x" * 200001): + with self.subTest(token_type=type(token).__name__): + result = self.wire( + self.apply(dict(case_name=self.target, preview_token=token)), 400 + ) + self.assertEqual(result["code"], "validation_error") + self.assertEqual(self.saved(), before) + + def test_noncompetitive_deadline_and_creation_switch_do_not_disable_existing_case( + self, + ): + PartnerProgram.objects.filter(pk=self.program.pk).update( + is_competitive=False, + solution_creation_enabled=False, + datetime_project_submission_ends=timezone.now() - timezone.timedelta(days=1), + ) + self.wire(self.apply(self.command_case()), schema="MutationReceipt") + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submission_snapshot) + + def test_confirmation_from_before_submission_is_stale_and_preserves_scores(self): + command = self.command_case() + self.assertEqual(self.submit().status_code, 200) + expert = create_rate_expert(program=self.program) + criterion = create_rate_criteria(self.program, min_value=0, max_value=10) + SolutionExpertAssignment.objects.create( + partner_program=self.program, solution=self.solution, expert=expert.expert + ) + SolutionScore.objects.create( + solution=self.solution, criteria=criterion, user=expert, value="0" + ) + before = self.saved() + response = self.wire(self.preview()) + self.assertTrue(response["can_apply"]) + self.assertIsNotNone(response["preview_token"]) + self.assertEqual(response["errors"], []) + self.assertEqual(response["score_count"], 1) + self.assertEqual(response["removed_assignments_count"], 0) + self.assertEqual( + self.wire(self.apply(command), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.saved(), before) + + def test_unexpected_draft_score_is_not_removed(self): + expert = create_rate_expert(program=self.program) + criterion = create_rate_criteria(self.program, min_value=0, max_value=10) + SolutionScore.objects.bulk_create( + [ + SolutionScore( + solution=self.solution, criteria=criterion, user=expert, value="0" + ) + ] + ) + before = self.saved() + result = self.wire(self.preview()) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertEqual(result["errors"][0]["code"], "evaluation_started") + self.assertEqual(self.saved(), before) + + def test_unexpected_draft_assignment_is_not_removed(self): + expert = create_rate_expert() + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=self.program, + solution=self.solution, + expert=expert.expert, + ) + ] + ) + before = self.saved() + result = self.wire(self.preview()) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertEqual(result["errors"][0]["code"], "evaluation_started") + self.assertEqual(self.saved(), before) + + def test_replay_bad_signature_payload_and_expiry_do_not_write(self): + command = self.command_case() + before = self.saved() + for token in ( + "not-a-token", + signing.dumps([], salt=actions.SALT), + signing.dumps({}, salt="solution-submission-actions-v1"), + ): + self.assertEqual( + self.wire(self.apply({**command, "preview_token": token}), 409)["code"], + "solution_action_conflict", + ) + with patch("django.core.signing.time.time", return_value=time() - 601): + expired = self.command_case() + self.assertEqual( + self.wire(self.apply(expired), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.saved(), before) + self.wire(self.apply(command), schema="MutationReceipt") + after = self.saved() + self.wire(self.apply(command), 409) + self.assertEqual(self.saved(), after) + + def test_actor_target_and_program_binding_cannot_be_reused(self): + command = self.command_case() + staff = create_user(is_staff=True) + self.wire(self.apply(command, actor=staff), 409) + self.wire(self.apply({**command, "case_name": "Задача 1"}), 409) + other = ProgramSolution.objects.create( + program=self.program, captain=create_user() + ) + self.wire( + call( + self.manager, + "post", + f"/programs/{self.program.pk}/management/solutions/{other.pk}/case/apply/", + command, + ), + 409, + ) + + def test_admin_changes_without_solution_version_bump_invalidate_proof(self): + changes = [ + lambda: self.solution.answers.filter( + field_id=self.fields["number"]["id"] + ).update(value=5), + lambda: self.solution.members.update(role="Другая роль"), + lambda: PartnerProgramField.objects.filter( + pk=self.fields["select"]["id"] + ).update(label="Новая подпись"), + lambda: ProgramSolution.objects.filter(pk=self.solution.pk).update( + name="Другое название" + ), + ] + for change in changes: + with self.subTest(change=change), transaction.atomic(): + command = self.command_case() + change() + changed = self.saved() + self.assertEqual( + self.wire(self.apply(command), 409)["code"], + "solution_action_conflict", + ) + self.assertEqual(self.saved(), changed) + transaction.set_rollback(True) + + def test_legacy_and_unconfigured_modes_are_explicit(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="legacy_project" + ) + self.assertEqual( + self.wire(self.preview(), 409)["code"], "solution_mode_unavailable" + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="solution", solution_template=None + ) + self.assertEqual(self.wire(self.preview(), 409)["code"], "form_not_configured") + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCaseActionConcurrencyTests(CaseFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL program locks") + super().setUp() + + def race(self, commands): + barrier, pids = Barrier(2), Queue() + + def run(command): + close_old_connections() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + pids.put(cursor.fetchone()[0]) + barrier.wait(timeout=10) + try: + command() + return "saved" + except SolutionError as error: + return error.payload["code"] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + controller = cursor.fetchone()[0] + futures = [pool.submit(run, command) for command in commands] + workers = [pids.get(timeout=10), pids.get(timeout=10)] + until, paths = monotonic() + 10, [] + while monotonic() < until: + with connection.cursor() as cursor: + cursor.execute( + "SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity WHERE datname=current_database()" + ) + blockers = dict(cursor.fetchall()) + paths = [] + for pid in workers: + seen, queue = set(), [pid] + while queue: + current = queue.pop() + if current in seen: + continue + seen.add(current) + queue.extend(blockers.get(current, [])) + paths.append(controller in seen) + if all(paths): + break + sleep(0.01) + self.assertEqual(paths, [True, True]) + return [f.result(timeout=20) for f in futures] + + def test_two_confirmations_have_one_change(self): + command = self.command_case() + self.solution.refresh_from_db() + before = self.solution.version + + def fn(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + self.assertCountEqual(self.race([fn, fn]), ["saved", "solution_action_conflict"]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, before + 1) + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, + self.target, + ) + + def test_confirmation_and_captain_patch_keep_one_ordered_version(self): + command = self.command_case() + patch_command = self.command(name="Локальная правка") + before = self.solution.version + + def fn(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + def update(): + return solution_answers.update_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **patch_command, + ) + + result = self.race([fn, update]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, before + 1) + if result[0] == "saved": + self.assertEqual(result, ["saved", "version_conflict"]) + self.assertNotEqual(self.solution.name, "Локальная правка") + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, + self.target, + ) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertEqual(self.solution.name, "Локальная правка") + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, + "Задача 1", + ) + + def test_confirmation_and_submission_never_change_a_frozen_snapshot(self): + command = self.command_case() + submission = self.command() + before = self.solution.version + + def change(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + def submit(): + return submit_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **submission, + ) + + result = self.race([change, submit]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, before + 1) + if result[0] == "saved": + self.assertEqual(result, ["saved", "version_conflict"]) + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submission_snapshot) + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, + self.target, + ) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertEqual(self.solution.status, "submitted") + answers = { + a["field_id"]: a["value"] + for a in self.solution.submission_snapshot["answers"] + } + self.assertEqual(answers[self.fields["select"]["id"]], "Задача 1") + self.assertEqual( + self.solution.answers.get(field_id=self.fields["select"]["id"]).value, + "Задача 1", + ) + + def test_two_submitted_confirmations_write_one_revision(self): + self.assertEqual(self.submit().status_code, 200) + command = self.command_case() + self.solution.refresh_from_db() + original = self.solution.submission_snapshot + version = self.solution.version + + def change(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + self.assertCountEqual( + self.race([change, change]), ["saved", "solution_action_conflict"] + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, version + 1) + journal = self.solution.administrative_changes.get() + self.assertEqual(journal.before_snapshot, original) + self.assertEqual(journal.after_snapshot, self.solution.submission_snapshot) + self.assertEqual(journal.case_name, self.target) + + def test_submitted_confirmation_and_score_update_preserve_the_winning_state(self): + self.assertEqual(self.submit().status_code, 200) + expert = create_rate_expert(program=self.program) + criterion = create_rate_criteria(self.program, min_value=0, max_value=10) + SolutionExpertAssignment.objects.create( + partner_program=self.program, solution=self.solution, expert=expert.expert + ) + score = SolutionScore.objects.create( + solution=self.solution, criteria=criterion, user=expert, value="0" + ) + command = self.command_case() + self.solution.refresh_from_db() + original = self.solution.submission_snapshot + version = self.solution.version + + def change(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + def rate(): + score.value = "7" + score.save() + + result = self.race([change, rate]) + score.refresh_from_db() + self.solution.refresh_from_db() + self.assertEqual(score.value, "7") + self.assertEqual(result[1], "saved") + if result[0] == "saved": + journal = self.solution.administrative_changes.get() + self.assertEqual(journal.before_snapshot, original) + self.assertEqual(journal.after_snapshot, self.solution.submission_snapshot) + self.assertEqual(journal.score_count, 1) + self.assertEqual(self.solution.version, version + 1) + else: + self.assertEqual(result[0], "solution_action_conflict") + self.assertEqual(self.solution.submission_snapshot, original) + self.assertEqual(self.solution.version, version) + self.assertEqual(self.solution.administrative_changes.count(), 0) diff --git a/partner_programs/tests/test_solution_case_analytics.py b/partner_programs/tests/test_solution_case_analytics.py new file mode 100644 index 00000000..76ad7c96 --- /dev/null +++ b/partner_programs/tests/test_solution_case_analytics.py @@ -0,0 +1,200 @@ +from django.db import connection +from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) +from partner_programs.tests.test_solution_forms import call +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionCaseAnalyticsTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.case = self.program.fields.get(pk=self.fields["select"]["id"]) + self.analytics_url = f"/programs/{self.program.pk}/management/solution-cases/" + + def analytics(self, actor=None): + response = call(actor or self.manager, "get", self.analytics_url) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("CaseSolutionAnalytics", response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + return response.data + + def choose(self, solution, value): + # Historical/empty values are read safely, even if supported writes reject them. + ProgramSolutionAnswer.objects.bulk_create( + [ProgramSolutionAnswer(solution=solution, field=self.case, value=value)] + ) + + def test_real_solutions_current_option_order_zero_rows_and_without_case(self): + valid = self.another_solution(submitted=False) + self.choose(valid, self.case.get_options_list()[0]) + for value in ["", "Устаревший кейс"]: + self.choose(self.another_solution(submitted=False), value) + self.another_solution(submitted=False) + result = self.analytics() + self.assertEqual( + [item["name"] for item in result["items"]], self.case.get_options_list() + ) + first = result["items"][0] + self.assertEqual( + [first[key] for key in ("solutions_total", "submitted", "not_submitted")], + [2, 1, 1], + ) + self.assertTrue(all(row["solutions_total"] == 0 for row in result["items"][1:])) + self.assertEqual(result["without_case"]["solutions_total"], 3) + self.assertEqual( + sum(row["solutions_total"] for row in result["items"]) + + result["without_case"]["solutions_total"], + self.program.solutions.count(), + ) + + def test_submitted_snapshot_ignores_changed_live_answers_captain_and_members(self): + initial = self.analytics()["items"][0] + user = create_user() + create_program_member(self.program, user=user) + ProgramSolution.objects.filter(pk=self.solution.pk).update(captain=user) + ProgramSolutionAnswer.objects.filter( + solution=self.solution, field=self.case + ).update(value="Устаревший кейс") + ProgramSolutionMember.objects.bulk_create( + [ProgramSolutionMember(solution=self.solution, user=user)] + ) + self.assertEqual(self.analytics()["items"][0], initial) + self.assertEqual(initial["participants_total"], 1) + + def test_accepted_registered_teams_not_pending_unregistered_or_legacy(self): + draft = self.another_solution(submitted=False) + self.choose(draft, "Задача 1") + member, pending, unregistered = [create_user() for _ in range(3)] + create_program_member(self.program, user=member) + create_program_member(self.program, user=pending) + ProgramSolutionMember.objects.create(solution=draft, user=member) + ProgramSolutionMember.objects.create(solution=draft, user=unregistered) + SolutionInvitation.objects.create(solution=draft, recipient=pending, role="Роль") + project = create_project(leader=pending) + PartnerProgramProject.objects.bulk_create( + [PartnerProgramProject(partner_program=self.program, project=project)] + ) + item = self.analytics()["items"][0] + self.assertEqual(item["participants_total"], 3) + self.assertEqual(item["solutions_total"], 2) + + def test_same_registered_person_is_unique_within_bucket_not_across_cases(self): + self.case.solution_options = [*self.case.get_options_list(), "Задача 2"] + self.case.save(update_fields=["solution_options"]) + person = create_user() + create_program_member(self.program, user=person) + for case in ["Задача 1", "Задача 1", "Задача 2"]: + draft = self.another_solution(submitted=False) + self.choose(draft, case) + # Historical duplicate memberships must not inflate a bucket. + ProgramSolutionMember.objects.bulk_create( + [ProgramSolutionMember(solution=draft, user=person)] + ) + items = {row["name"]: row for row in self.analytics()["items"]} + self.assertEqual(items["Задача 1"]["participants_total"], 4) + self.assertEqual(items["Задача 2"]["participants_total"], 2) + + def test_noncompetitive_preserves_draft_without_artificial_submission(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + draft = self.another_solution(submitted=False) + result = self.analytics() + self.assertFalse(result["submission_applicable"]) + for row in [*result["items"], result["without_case"]]: + self.assertIsNone(row["submitted"]) + self.assertIsNone(row["not_submitted"]) + self.assertEqual(result["without_case"]["solutions_total"], 1) + draft.refresh_from_db() + self.assertEqual(draft.status, "draft") + + def test_unconfigured_case_and_missing_current_options_are_without_case(self): + self.program.fields.filter(pk=self.case.pk).update(name="retired_case") + result = self.analytics() + self.assertFalse(result["configured"]) + self.assertEqual(result["items"], []) + self.assertEqual(result["without_case"]["solutions_total"], 1) + self.assertEqual(result["without_case"]["participants_total"], 1) + + def test_another_program_of_same_captain_does_not_change_case_aggregates(self): + from partner_programs.solution_api import create_solution + from partner_programs.tests.test_solution_foundation import solution_program + + before = self.analytics() + other = solution_program(is_competitive=True) + create_program_member(other, user=self.captain) + foreign, _ = create_solution(other.pk, self.captain, {}) + self.assertNotEqual(foreign.program_id, self.program.pk) + self.assertEqual(self.analytics(), before) + + def test_scope_and_errors_never_grant_participant_or_expert_manager_data(self): + for user in [self.captain, self.expert, create_user()]: + response = call(user, "get", self.analytics_url) + self.assertEqual(response.status_code, 403) + self.assert_schema("Error", response.data) + response = APIClient().get(self.analytics_url) + self.assertEqual(response.status_code, 401) + self.assert_schema("Error", response.data) + self.assertEqual( + call(self.manager, "get", self.analytics_url + "?user_id=1").status_code, + 400, + ) + other = create_partner_program() + url = f"/programs/{other.pk}/management/solution-cases/" + self.assertEqual(call(self.manager, "get", url).status_code, 403) + other.managers.add(self.manager) + response = call(self.manager, "get", url) + self.assertEqual(response.status_code, 409) + self.assert_schema("Error", response.data) + self.assertEqual(response.data["code"], "solution_mode_unavailable") + self.assertEqual( + call( + self.manager, "get", "/programs/90000000/management/solution-cases/" + ).status_code, + 404, + ) + self.assertEqual( + self.analytics(create_user(is_staff=True))["program_id"], self.program.pk + ) + + def test_bounded_queries_read_only_and_no_private_payload(self): + with CaptureQueriesContext(connection) as before: + result = self.analytics() + self.assertLessEqual(len(before), 10) + for _ in range(8): + self.another_solution(submitted=False) + with CaptureQueriesContext(connection) as after: + self.analytics() + self.assertEqual(len(before), len(after)) + self.assertFalse( + any( + row["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")) + for row in after + ) + ) + self.assertEqual( + set(result), + { + "program_id", + "configured", + "submission_applicable", + "items", + "without_case", + }, + ) + self.assertFalse(ProgramSolution.objects.exclude(program=self.program).exists()) diff --git a/partner_programs/tests/test_solution_competitive_publication.py b/partner_programs/tests/test_solution_competitive_publication.py new file mode 100644 index 00000000..dd3ae02a --- /dev/null +++ b/partner_programs/tests/test_solution_competitive_publication.py @@ -0,0 +1,408 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from queue import Queue +from time import monotonic, sleep + +from django.db import close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram, PartnerProgramField +from partner_programs.services import ( + solution_archive_actions, + solution_case_actions, + solution_member_actions, +) +from partner_programs.services.publishing import publish_finished_program_solutions +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionAdministrativeChange, + SolutionInvitation, +) +from partner_programs.tests.helpers import create_program_member, create_user +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_publication import PublicationFixture +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert +from projects.models import Project + + +FAST_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +class CompetitivePublicationFixture(PublicationFixture): + def setUp(self): + self.seed_publication() + self.program.is_competitive = True + self.program.save(update_fields=["is_competitive"]) + + def submit_ready(self): + response = self.submit() + self.assertEqual(response.status_code, 200, response.data) + self.solution.refresh_from_db() + self.now = timezone.now() + + def archive(self): + data = dict(ids=[self.solution.pk]) + preview = solution_archive_actions.preview(self.program.pk, self.manager, data) + return solution_archive_actions.apply( + self.program.pk, + self.manager, + dict(**data, preview_token=preview["preview_token"]), + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class CompetitivePublicationTests(CompetitivePublicationFixture, TestCase): + def test_submitted_only_is_published_without_altering_submission_or_projects(self): + draft = self.other_solution(name="Несданная работа") + self.submit_ready() + before = ProgramSolution.all_objects.filter(pk=self.solution.pk).values().get() + projects = list(Project.objects.order_by("pk").values()) + self.assertEqual(self.publish(), 1) + after = ProgramSolution.all_objects.filter(pk=self.solution.pk).values().get() + self.assertEqual(after, dict(before, published_at=self.now)) + draft.refresh_from_db() + self.assertIsNone(draft.published_at) + self.assertEqual(self.read()["count"], 1) + self.assertEqual(self.read(self.solution)["application"]["name"], before["name"]) + self.assertEqual(list(Project.objects.order_by("pk").values()), projects) + + def test_published_materials_search_and_roster_use_only_the_applied_snapshot(self): + member, pending = create_user(), create_user() + for user in (member, pending): + create_program_member( + self.program, user=user, data={"secret": "private-form"} + ) + ProgramSolutionMember.objects.create( + solution=self.solution, user=member, role="Дизайнер" + ) + SolutionInvitation.objects.create( + solution=self.solution, + recipient=pending, + motivational_letter="private-invite", + ) + file = self.upload() + self.assertEqual( + self.save_answers( + [dict(field_id=self.fields["file"]["id"], value=dict(url=file.pk))], + name="Сданное название", + ).status_code, + 200, + ) + self.submit_ready() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + ProgramSolution.objects.filter(pk=self.solution.pk).update(name="Живое имя") + ProgramSolutionAnswer.objects.filter( + solution=self.solution, field_id=self.fields["number"]["id"] + ).update(value=999) + PartnerProgramField.objects.filter(pk=self.fields["number"]["id"]).update( + label="Новая живая подпись" + ) + added = PartnerProgramField.objects.create( + partner_program=self.program, + name="after_submission", + field_type="textarea", + label="Новое обязательное поле", + is_required=True, + purpose="other", + position=99, + ) + self.assertEqual(self.publish(), 1) + data = self.read(self.solution) + self.assertEqual(data["application"]["name"], "Сданное название") + self.assertEqual(self.read(query="?search=Сданное")["count"], 1) + self.assertEqual(self.read(query="?search=Живое")["count"], 0) + answers = {row["field_id"]: row["value"] for row in data["answers"]} + self.assertEqual(answers[self.fields["number"]["id"]], 0) + self.assertIs(answers[self.fields["checkbox"]["id"]], False) + self.assertEqual(answers[self.fields["file"]["id"]], {"url": file.pk}) + self.assertEqual( + data["fields"][2]["label"], snapshot["form"]["fields"][2]["label"] + ) + self.assertNotIn(added.pk, [field["id"] for field in data["fields"]]) + self.assertCountEqual( + [row["user"]["id"] for row in data["team"]["members"]], + [self.captain.pk, member.pk], + ) + encoded = json.dumps(data) + for secret in ( + member.email, + pending.email, + "private-form", + "private-invite", + "submission_snapshot", + "permissions", + ): + self.assertNotIn(secret, encoded) + + def test_draft_with_marker_is_hidden_for_every_role_and_not_counted(self): + ProgramSolution.objects.filter(pk=self.solution.pk).update(published_at=self.now) + for actor in (None, self.captain, self.manager, create_user()): + page = call(actor, "get", self.public_url) + self.assertEqual(page.status_code, 200, page.data) + self.assertEqual(page.data["count"], 0) + detail = call(actor, "get", self.public_url + f"{self.solution.pk}/") + self.assertEqual(detail.status_code, 404, detail.data) + self.assert_schema("Error", detail.data) + + def test_pagination_counts_submitted_only_and_foreign_program_cannot_read_it(self): + self.submit_ready() + draft, second = self.other_solution(), self.other_solution(name="Второе решение") + root = f"/programs/{self.program.pk}/solutions/{second.pk}/" + current = call(second.captain, "get", root).data + saved = call( + second.captain, + "patch", + root, + dict( + expected_version=current["version"], + expected_form_revision=current["form"]["revision"], + answers=list( + self.solution.answers.order_by("pk").values("field_id", "value") + ), + ), + ) + self.assertEqual(saved.status_code, 200, saved.data) + submitted = call( + second.captain, + "post", + root + "submit/", + dict( + expected_version=saved.data["version"], + expected_form_revision=saved.data["form"]["revision"], + ), + ) + self.assertEqual(submitted.status_code, 200, submitted.data) + ProgramSolution.objects.filter(pk=draft.pk).update(published_at=self.now) + self.assertEqual(self.publish(), 2) + page = self.read(query="?limit=1") + self.assertEqual(page["count"], 2) + self.assertEqual( + page["results"][0]["application"]["solution_id"], self.solution.pk + ) + following = APIClient().get(page["next"]) + self.assertEqual(following.status_code, 200, following.data) + self.assertEqual( + following.data["results"][0]["application"]["solution_id"], second.pk + ) + self.assertIsNone(following.data["next"]) + self.assertIsNotNone(following.data["previous"]) + foreign = self.other_solution(program=self.other_program()) + for actor in (None, self.manager, self.captain): + response = call( + actor, + "get", + f"/programs/{foreign.program_id}/published-solutions/{self.solution.pk}/", + ) + self.assertEqual(response.status_code, 404, response.data) + + def other_program(self): + from partner_programs.tests.helpers import create_partner_program + + return create_partner_program( + solution_storage_mode="solution", is_competitive=True + ) + + def test_flag_date_boundary_repeat_and_disabled_creation_keep_first_marker(self): + self.submit_ready() + for changes in ( + dict(publish_projects_after_finish=False), + dict(datetime_finished=self.now + timezone.timedelta(seconds=1)), + ): + PartnerProgram.objects.filter(pk=self.program.pk).update(**changes) + self.assertEqual(publish_finished_program_solutions(now=self.now), 0) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=True, datetime_finished=self.now + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False + ) + self.assertEqual(self.publish(), 1) + later = self.now + timezone.timedelta(days=1) + self.assertEqual(publish_finished_program_solutions(now=later), 0) + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=False, datetime_finished=later + ) + self.assertEqual(self.read()["count"], 1) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + + def test_get_and_first_submission_do_not_publish_without_job(self): + self.assertEqual(self.read()["count"], 0) + self.submit_ready() + self.assertEqual(self.read()["count"], 0) + self.assertEqual( + APIClient().get(self.public_url + f"{self.solution.pk}/").status_code, 404 + ) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + self.assertEqual(self.publish(), 1) + self.assertEqual(self.read()["count"], 1) + + def test_public_reads_never_query_or_expose_private_scores_and_assignments(self): + self.submit_ready() + criterion = create_rate_criteria(self.program, type="str") + expert = create_rate_expert(program=self.program) + SolutionExpertAssignment.objects.create( + solution=self.solution, expert=expert.expert, partner_program=self.program + ) + SolutionScore.objects.create( + solution=self.solution, + criteria=criterion, + user=expert, + value="private-score", + ) + self.assertEqual(self.publish(), 1) + with CaptureQueriesContext(connection) as queries: + data = self.read(self.solution) + self.assertFalse(any("project_rates_" in row["sql"] for row in queries)) + encoded = json.dumps(data) + for secret in ( + "private-score", + "criteria", + "assignments", + "scores", + expert.email, + ): + self.assertNotIn(secret, encoded) + self.assertEqual(APIClient().get(self.url).status_code, 401) + + def test_confirmed_case_member_revisions_are_public_without_private_history(self): + member = create_user() + create_program_member(self.program, user=member) + ProgramSolutionMember.objects.create( + solution=self.solution, user=member, role="Разработчик" + ) + PartnerProgramField.objects.filter(pk=self.fields["select"]["id"]).update( + solution_options=["Задача 1", "Задача 2"], options="Задача 1|Задача 2" + ) + self.submit_ready() + original = copy.deepcopy(self.solution.submission_snapshot) + self.assertEqual(self.publish(), 1) + case = {"case_name": "Задача 2"} + preview = solution_case_actions.preview( + self.program.pk, self.solution.pk, self.manager, case + ) + solution_case_actions.apply( + self.program.pk, + self.solution.pk, + self.manager, + dict(**case, preview_token=preview["preview_token"]), + ) + remove = {"user_id": member.pk} + preview = solution_member_actions.preview( + self.program.pk, self.solution.pk, self.manager, remove + ) + solution_member_actions.apply( + self.program.pk, + self.solution.pk, + self.manager, + dict(**remove, preview_token=preview["preview_token"]), + ) + data = self.read(self.solution) + answers = {row["field_id"]: row["value"] for row in data["answers"]} + self.assertEqual(answers[self.fields["select"]["id"]], "Задача 2") + self.assertEqual(data["team"]["accepted_count"], 1) + events = SolutionAdministrativeChange.objects.filter( + solution=self.solution + ).order_by("pk") + self.assertEqual(events.count(), 2) + self.assertEqual(events.first().before_snapshot, original) + self.assertNotIn("before_snapshot", json.dumps(data)) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + + def test_archive_before_or_after_publication_excludes_work_and_retains_snapshot(self): + self.submit_ready() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + self.assertEqual(self.publish(), 1) + self.archive() + self.assertEqual(self.read()["count"], 0) + self.assertEqual( + APIClient().get(self.public_url + f"{self.solution.pk}/").status_code, 404 + ) + self.assertEqual(self.publish(), 0) + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + self.assertEqual(self.solution.published_at, self.now) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class CompetitivePublicationLockTests(CompetitivePublicationFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL program locks and pg_blocking_pids") + super().setUp() + + def waiting_worker(self, pool, command): + backend = Queue() + + def run(): + close_old_connections() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + backend.put(cursor.fetchone()[0]) + return command() + finally: + close_old_connections() + + future = pool.submit(run) + pid, deadline = backend.get(timeout=10), monotonic() + 10 + while monotonic() < deadline: + with connection.cursor() as cursor: + cursor.execute("SELECT cardinality(pg_blocking_pids(%s))", [pid]) + if cursor.fetchone()[0]: + self.assertFalse(future.done()) + return future + sleep(0.02) + self.fail("Publisher/archive worker не дождался program lock.") + + def test_waiting_publisher_rechecks_archived_work_after_program_lock(self): + self.assertEqual(connection.vendor, "postgresql") + self.submit_ready() + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + future = self.waiting_worker(pool, self.publish) + self.archive() + self.assertEqual(future.result(timeout=10), 0) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + self.assertIsNotNone(self.solution.archived_at) + self.assertEqual(self.read()["count"], 0) + + def test_waiting_archive_preserves_marker_and_removes_public_visibility(self): + self.assertEqual(connection.vendor, "postgresql") + self.submit_ready() + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + self.assertEqual(self.publish(), 1) + future = self.waiting_worker(pool, self.archive) + self.assertEqual(future.result(timeout=10)["changed_count"], 1) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + self.assertEqual(self.read()["count"], 0) + + def test_waiting_repeat_publisher_preserves_first_publication_time(self): + self.assertEqual(connection.vendor, "postgresql") + self.submit_ready() + first = self.now + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + self.assertEqual(self.publish(), 1) + self.now += timezone.timedelta(seconds=1) + future = self.waiting_worker(pool, self.publish) + self.assertEqual(future.result(timeout=10), 0) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, first) diff --git a/partner_programs/tests/test_solution_creation_switch.py b/partner_programs/tests/test_solution_creation_switch.py new file mode 100644 index 00000000..68da9e89 --- /dev/null +++ b/partner_programs/tests/test_solution_creation_switch.py @@ -0,0 +1,558 @@ +"""REL01: остановка только нового создания без потери действующих решений.""" + +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from io import StringIO +from pathlib import Path +from queue import Queue +from time import monotonic, sleep + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import close_old_connections, connection, transaction +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram +from partner_programs.solution_api import create_solution +from partner_programs.solution_errors import MAX_ID, SolutionError +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, + project_apply_payload, +) +from partner_programs.tests.test_solution_foundation import solution_program +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_publication import PublicationFixture +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates.tests.test_solution_evaluation import EvaluationFixture +from projects.models import Project + +FAST_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +def toggle(program_id, enabled, *, apply=True): + output = StringIO() + arguments = ["--program-id", str(program_id), "--enable" if enabled else "--disable"] + if apply: + arguments.append("--apply") + call_command("set_solution_creation", *arguments, stdout=output) + return json.loads(output.getvalue()) + + +def domain_state(model_registry=None): + selected = ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, + Project, + ) + if model_registry is not None: + selected = tuple( + model_registry.get_model(model._meta.label) for model in selected + ) + return { + model._meta.label: list(model.objects.order_by("pk").values()) + for model in selected + } + + +def assert_schema(name, data): + from jsonschema import Draft202012Validator, FormatChecker + + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + {"components": spec["components"], "$ref": f"#/components/schemas/{name}"}, + format_checker=FormatChecker(), + ).validate(data) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationSwitchTests(TestCase): + def setUp(self): + self.program = solution_program( + solution_creation_enabled=False, is_competitive=True + ) + self.captain = create_user() + create_program_member(self.program, user=self.captain) + self.url = f"/programs/{self.program.pk}/solutions/" + self.context_url = f"/programs/{self.program.pk}/solution-context/" + + def test_default_is_disabled_and_flag_is_not_a_client_setting(self): + program = create_partner_program(solution_storage_mode="solution") + self.assertFalse(program.solution_creation_enabled) + self.assertEqual(create_partner_program().solution_storage_mode, "legacy_project") + field = PartnerProgram._meta.get_field("solution_creation_enabled") + self.assertFalse(field.editable) + from partner_programs.serializers.programs import ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ) + + for serializer in ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ): + self.assertNotIn("solution_creation_enabled", serializer().fields) + + def test_disabled_context_and_post_agree_without_domain_writes(self): + before = domain_state() + context = call(self.captain, "get", self.context_url) + self.assertEqual(context.status_code, 200) + self.assertFalse(context.data["can_create"]) + self.assertEqual(context.data["blocked_reason"], "solution_creation_disabled") + self.assertTrue(context.data["submission_open"]) + self.assertIsNone(context.data["current_application"]) + assert_schema("Context", context.data) + result = call(self.captain, "post", self.url) + self.assertEqual(result.status_code, 409) + self.assertEqual(result.data["code"], "solution_creation_disabled") + assert_schema("Error", result.data) + self.assertEqual(domain_state(), before) + + def test_enable_disable_reenable_is_scoped_and_repeat_captain_is_unchanged(self): + other = solution_program() + other_user = create_user() + create_program_member(other, user=other_user) + self.assertEqual( + call(other_user, "post", f"/programs/{other.pk}/solutions/").status_code, + 201, + ) + toggle(self.program.pk, True) + self.assertTrue(call(self.captain, "get", self.context_url).data["can_create"]) + created = call(self.captain, "post", self.url, {"name": "Первая заявка"}) + self.assertEqual(created.status_code, 201, created.data) + before = domain_state() + toggle(self.program.pk, False) + repeated = call(self.captain, "post", self.url, {"name": "Не менять"}) + self.assertEqual(repeated.status_code, 200) + self.assertEqual(repeated.data, created.data) + self.assertEqual(domain_state(), before) + context = call(self.captain, "get", self.context_url).data + self.assertEqual(context["blocked_reason"], "already_has_application") + self.assertEqual( + context["current_application"]["solution_id"], created.data["id"] + ) + newcomer = create_user() + create_program_member(self.program, user=newcomer) + self.assertEqual(call(newcomer, "post", self.url).status_code, 409) + toggle(self.program.pk, True) + self.assertEqual(call(newcomer, "post", self.url).status_code, 201) + other.refresh_from_db() + self.assertTrue(other.solution_creation_enabled) + self.assertEqual(ProgramSolution.objects.count(), 3) + self.assertFalse(Project.objects.exists()) + + def test_accepted_member_opens_current_application_without_second_creation(self): + toggle(self.program.pk, True) + created = call(self.captain, "post", self.url).data + member = create_user() + create_program_member(self.program, user=member) + ProgramSolutionMember.objects.create(solution_id=created["id"], user=member) + toggle(self.program.pk, False) + context = call(member, "get", self.context_url).data + self.assertEqual(context["current_application"]["solution_id"], created["id"]) + self.assertEqual(context["blocked_reason"], "already_has_application") + result = call(member, "post", self.url) + self.assertEqual(result.status_code, 409) + self.assertEqual(result.data["code"], "already_in_program_team") + self.assertEqual(ProgramSolution.objects.count(), 1) + + def test_rights_precede_switch_and_switch_precedes_deadline_and_form(self): + self.assertEqual(APIClient().post(self.url, {}, format="json").status_code, 401) + for outsider in [create_user(), create_user(is_staff=True)]: + context = call(outsider, "get", self.context_url).data + self.assertEqual(context["blocked_reason"], "not_program_member") + result = call(outsider, "post", self.url) + self.assertEqual(result.status_code, 403) + self.assertEqual(result.data["code"], "not_program_member") + manager = create_user() + self.program.managers.add(manager) + self.assertEqual( + call(manager, "post", self.url).data["code"], "solution_creation_disabled" + ) + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(days=1) + ) + self.program.save(update_fields=["datetime_project_submission_ends"]) + for actor in [self.captain, manager]: + self.assertEqual( + call(actor, "get", self.context_url).data["blocked_reason"], + "solution_creation_disabled", + ) + self.assertEqual( + call(actor, "post", self.url).data["code"], "solution_creation_disabled" + ) + toggle(self.program.pk, True) + self.assertEqual( + call(self.captain, "post", self.url).data["code"], "submission_closed" + ) + self.program.datetime_project_submission_ends = None + self.program.solution_template = None + self.program.save( + update_fields=["datetime_project_submission_ends", "solution_template"] + ) + self.assertEqual( + call(self.captain, "post", self.url).data["code"], "form_not_configured" + ) + toggle(self.program.pk, False) + self.assertEqual( + call(self.captain, "get", self.context_url).data["blocked_reason"], + "solution_creation_disabled", + ) + + def test_client_cannot_override_switch_and_unknown_program_remains_hidden(self): + result = call(self.captain, "post", self.url, {"solution_creation_enabled": True}) + self.assertEqual(result.status_code, 400) + for program_id in [MAX_ID, MAX_ID + 1, 0]: + result = call(self.captain, "post", f"/programs/{program_id}/solutions/") + self.assertEqual(result.status_code, 404) + self.assertFalse(ProgramSolution.objects.exists()) + + def test_legacy_creation_is_unchanged_while_switch_is_disabled(self): + program = create_partner_program() + create_program_member(program, user=self.captain) + context = call( + self.captain, "get", f"/programs/{program.pk}/solution-context/" + ).data + self.assertEqual(context["blocked_reason"], "legacy_mode") + result = call( + self.captain, + "post", + f"/programs/{program.pk}/projects/apply/", + project_apply_payload(), + ) + self.assertEqual(result.status_code, 201, result.data) + self.assertEqual(Project.objects.count(), 1) + self.assertFalse(ProgramSolution.objects.exists()) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationCommandTests(TestCase): + def test_stale_program_save_does_not_override_either_operator_switch(self): + program = solution_program() + toggle(program.pk, False) + program.description = "Изменение обычной карточки" + program.save() + program.refresh_from_db() + self.assertFalse(program.solution_creation_enabled) + toggle(program.pk, True) + program.name = "Другая подпись" + program.save() + program.refresh_from_db() + self.assertTrue(program.solution_creation_enabled) + self.assertEqual(program.description, "Изменение обычной карточки") + + def test_preview_apply_idempotency_and_only_one_column_changes(self): + program, other = solution_program(), solution_program() + captain = create_user() + create_program_member(program, user=captain) + create_solution(program.pk, captain, {}) + before = domain_state() + metadata = list(PartnerProgram.objects.order_by("pk").values()) + preview = toggle(program.pk, False, apply=False) + self.assertFalse(preview["applied"]) + self.assertFalse(preview["changed"]) + self.assertEqual(list(PartnerProgram.objects.order_by("pk").values()), metadata) + receipt = toggle(program.pk, False) + self.assertTrue(receipt["changed"]) + self.assertTrue(receipt["previous_enabled"]) + self.assertFalse(receipt["requested_enabled"]) + metadata[0]["solution_creation_enabled"] = False + self.assertEqual(list(PartnerProgram.objects.order_by("pk").values()), metadata) + self.assertFalse(toggle(program.pk, False)["changed"]) + self.assertEqual(domain_state(), before) + other.refresh_from_db() + self.assertTrue(other.solution_creation_enabled) + + def test_invalid_unknown_ids_and_legacy_enable_do_not_write(self): + program = create_partner_program() + before = list(PartnerProgram.objects.values()) + for identifier in [-1, 0, MAX_ID, MAX_ID + 1, program.pk]: + with self.subTest(identifier=identifier), self.assertRaises(CommandError): + toggle(identifier, True) + self.assertEqual(list(PartnerProgram.objects.values()), before) + self.assertFalse(toggle(program.pk, False)["changed"]) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS, SELECTEL_SWIFT_URL="https://cdn.test") +class SolutionCreationExistingDraftTests(SubmissionFixture, TestCase): + def setUp(self): + self.ready() + toggle(self.program.pk, False) + + def invite(self): + user = create_user() + create_program_member(self.program, user=user) + self.solution.refresh_from_db() + result = call( + self.captain, + "post", + self.url + "invitations/", + dict( + recipient_user_id=user.pk, + expected_solution_version=self.solution.version, + role="Разработчик", + ), + ) + self.assertEqual(result.status_code, 201, result.data) + return user, result.data["invitation"] + + def accept(self, user, invitation): + result = call( + user, + "post", + f"/solution-invitations/{invitation['id']}/accept/", + {"expected_version": invitation["version"]}, + ) + self.assertEqual(result.status_code, 200, result.data) + + def test_existing_save_file_invite_accept_submit_and_pending_cleanup_continue(self): + upload = self.upload() + result = self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.pk}}], + name="Работа продолжается", + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertTrue(result.data["permissions"]["can_edit"]) + member, invitation = self.invite() + self.solution.refresh_from_db() + edited = call( + self.captain, + "patch", + f"/solution-invitations/{invitation['id']}/", + dict( + expected_version=invitation["version"], + expected_solution_version=self.solution.version, + role="Аналитик", + ), + ) + self.assertEqual(edited.status_code, 200, edited.data) + self.accept(member, edited.data["invitation"]) + declined_user, declined = self.invite() + _, revoked = self.invite() + result = self.submit() + self.assertEqual(result.status_code, 200, result.data) + assert_schema("Solution", result.data) + self.solution.refresh_from_db() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + self.assertEqual(result.data["team"]["accepted_count"], 2) + self.assertEqual(result.data["name"], "Работа продолжается") + self.assertFalse(result.data["permissions"]["can_edit"]) + self.assertEqual(self.save_answers(name="Запрещено").status_code, 409) + self.assertEqual(self.submit().data["code"], "already_submitted") + self.assertEqual( + call( + declined_user, + "post", + f"/solution-invitations/{declined['id']}/decline/", + {"expected_version": declined["version"]}, + ).status_code, + 200, + ) + self.solution.refresh_from_db() + self.assertEqual( + call( + self.captain, + "delete", + f"/solution-invitations/{revoked['id']}/?expected_version={revoked['version']}" + f"&expected_solution_version={self.solution.version}", + ).status_code, + 204, + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + for actor in [self.captain, member, self.manager]: + read = call(actor, "get", self.url) + self.assertEqual(read.status_code, 200) + self.assertEqual(read.data["answers"], snapshot["answers"]) + self.assertFalse(self.solution.invitations.filter(status="pending").exists()) + self.program.refresh_from_db() + self.assertFalse(self.program.solution_creation_enabled) + + def test_existing_transfer_remove_and_leave_keep_their_rules(self): + member, invitation = self.invite() + self.accept(member, invitation) + removable, invitation = self.invite() + self.accept(removable, invitation) + self.solution.refresh_from_db() + result = call( + self.captain, + "delete", + self.url + + f"members/{removable.pk}/?expected_solution_version={self.solution.version}", + ) + self.assertEqual(result.status_code, 200, result.data) + self.solution.refresh_from_db() + result = call( + self.captain, + "post", + self.url + "captain/", + dict( + new_captain_user_id=member.pk, + expected_solution_version=self.solution.version, + ), + ) + self.assertEqual(result.status_code, 200, result.data) + self.solution.refresh_from_db() + self.assertEqual(self.solution.captain_id, member.pk) + result = call( + self.captain, + "post", + self.url + "leave/", + {"expected_solution_version": self.solution.version}, + ) + self.assertEqual(result.status_code, 204, result.data) + self.assertEqual( + list(self.solution.members.values_list("user_id", flat=True)), [member.pk] + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationExistingReadersTests(EvaluationFixture, TestCase): + def test_expert_evaluation_and_manager_results_continue_while_creation_disabled(self): + self.seed_evaluation() + toggle(self.program.pk, False) + self.assertEqual(call(self.expert, "get", self.evaluation_url).status_code, 200) + self.assertEqual(self.rate().status_code, 200) + self.assertEqual(call(self.expert, "get", self.list_url).status_code, 200) + self.assertEqual( + call( + self.manager, + "get", + f"/programs/{self.program.pk}/management/solution-results/", + ).status_code, + 200, + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationExistingPublicTests(PublicationFixture, TestCase): + def test_noncompetitive_publisher_and_anonymous_materials_ignore_creation_switch( + self, + ): + self.seed_publication() + toggle(self.program.pk, False) + self.assertEqual(self.publish(), 1) + self.assertEqual(self.read()["count"], 1) + self.assertEqual( + self.read(self.solution)["application"]["solution_id"], self.solution.pk + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationSwitchMigrationTests(SubmissionFixture, TransactionTestCase): + def test_existing_programs_and_solution_data_survive_disabled_default(self): + self.ready() + self.assertEqual(self.submit().status_code, 200) + create_partner_program() + columns = [ + f.attname + for f in PartnerProgram._meta.fields + if f.name != "solution_creation_enabled" + ] + before_programs = list(PartnerProgram.objects.order_by("pk").values(*columns)) + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("partner_programs", "0033_solution_publication")] + after = [("partner_programs", "0034_solution_creation_switch")] + historical = executor.loader.project_state(after).apps + before_data = domain_state(historical) + try: + executor.migrate(before) + executor = MigrationExecutor(connection) + executor.migrate(after) + self.assertFalse( + PartnerProgram.objects.filter(solution_creation_enabled=True).exists() + ) + self.assertEqual( + list(PartnerProgram.objects.order_by("pk").values(*columns)), + before_programs, + ) + self.assertEqual(domain_state(historical), before_data) + finally: + MigrationExecutor(connection).migrate(latest) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionCreationSwitchConcurrencyTests(TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL row locks") + self.program = solution_program(is_competitive=True) + self.captain = create_user() + create_program_member(self.program, user=self.captain) + + def assert_worker_blocked(self, worker_pid): + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + main_pid = cursor.fetchone()[0] + until = monotonic() + 10 + while monotonic() < until: + cursor.execute("SELECT pg_blocking_pids(%s)", [worker_pid]) + if main_pid in cursor.fetchone()[0]: + return + sleep(0.01) + self.fail("Worker did not wait for the program row lock") + + def worker(self, ready, action): + close_old_connections() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + ready.put(cursor.fetchone()[0]) + return action() + finally: + close_old_connections() + + def test_create_waiting_for_disable_lock_rechecks_committed_flag(self): + def create(): + try: + create_solution(self.program.pk, self.captain, {}) + except SolutionError as error: + return error.payload["code"] + return "created" + + ready = Queue() + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + toggle(self.program.pk, False) + future = pool.submit(self.worker, ready, create) + self.assert_worker_blocked(ready.get(timeout=10)) + self.assertEqual(future.result(timeout=15), "solution_creation_disabled") + self.assertFalse(ProgramSolution.objects.exists()) + self.assertFalse(ProgramSolutionMember.objects.exists()) + + def test_disable_waits_for_created_solution_and_preserves_existing_access(self): + ready = Queue() + with ThreadPoolExecutor(max_workers=1) as pool: + with transaction.atomic(): + solution, created = create_solution(self.program.pk, self.captain, {}) + self.assertTrue(created) + future = pool.submit( + self.worker, ready, lambda: toggle(self.program.pk, False) + ) + self.assert_worker_blocked(ready.get(timeout=10)) + self.assertTrue(future.result(timeout=15)["changed"]) + repeated, created = create_solution(self.program.pk, self.captain, {}) + self.assertFalse(created) + self.assertEqual(repeated.pk, solution.pk) + self.assertEqual(ProgramSolution.objects.count(), 1) + self.assertEqual(ProgramSolutionMember.objects.count(), 1) diff --git a/partner_programs/tests/test_solution_dev_migrations.py b/partner_programs/tests/test_solution_dev_migrations.py new file mode 100644 index 00000000..6b5f990b --- /dev/null +++ b/partner_programs/tests/test_solution_dev_migrations.py @@ -0,0 +1,306 @@ +import hashlib +import json +from pathlib import Path + +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.db.migrations.loader import MigrationLoader +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.certificate_models import ProgramCertificate +from partner_programs.tests.helpers import create_user +from projects.models import Project + + +COMMON = ("partner_programs", "0030_program_solution_foundation") +SOLUTION = ("partner_programs", "0038_solution_registration_removal_audit") +CERTIFICATE = ("partner_programs", "0031_programcertificate_rendered_batch") +MERGED = ("partner_programs", "0039_merge_solution_certificate_branches") + + +def plain(value): + if isinstance(value, (bytes, bytearray, memoryview)): + return {"size": len(value), "sha256": hashlib.sha256(value).hexdigest()} + if isinstance(value, dict): + return {key: plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [plain(item) for item in value] + return json.loads(json.dumps(value, default=str)) + + +class SolutionDevGraphTests(TestCase): + def test_single_leaf_empty_merge_and_unchanged_upstream_migration(self): + loader = MigrationLoader(None) + self.assertEqual(loader.detect_conflicts(), {}) + self.assertEqual(loader.graph.leaf_nodes("partner_programs"), [MERGED]) + migration = loader.disk_migrations[MERGED] + self.assertCountEqual(migration.dependencies, [CERTIFICATE, SOLUTION]) + self.assertEqual(migration.operations, []) + source = ( + ( + Path(__file__).resolve().parents[1] + / "migrations/0031_programcertificate_rendered_batch.py" + ) + .read_bytes() + .replace(b"\r\n", b"\n") + ) + self.assertEqual( + hashlib.sha256(source).hexdigest(), + "2fe9364b189b5f7db866d67a77eb3d171a131f250215a621ac7bb280ec005e66", + ) + field = ProgramCertificate._meta.get_field("rendered_batch") + self.assertTrue(field.null) + self.assertTrue(field.blank) + self.assertEqual(field.remote_field.related_name, "rendered_certificates") + + +class SolutionDevMigrationDataTests(TransactionTestCase): + def setUp(self): + self.latest = MigrationExecutor(connection).loader.graph.leaf_nodes() + + def before(self, branch): + # Только isolated test DB: возвращаем обе ветки к общей точке, затем + # применяем выбранный порядок. Main никогда не откатывается этим тестом. + executor = MigrationExecutor(connection) + executor.migrate([COMMON]) + targets = [branch] + if branch == SOLUTION: + targets.append(("project_rates", "0011_solution_score_value_1000")) + executor = MigrationExecutor(connection) + executor.migrate(targets) + return executor.loader.project_state(targets).apps + + def snapshot(self, model, fields=None): + fields = fields or [field.attname for field in model._meta.concrete_fields] + manager = getattr(model, "all_objects", model.objects) + return json.dumps( + plain(list(manager.order_by("pk").values(*fields))), + sort_keys=True, + ensure_ascii=False, + ) + + def seed(self, apps, branch): + now = timezone.now() + captain, member = create_user(), create_user() + program_model = apps.get_model("partner_programs", "PartnerProgram") + programs = [] + for index, mode in enumerate(["legacy_project", "solution"]): + programs.append( + program_model.objects.create( + name=f"Совместимость {mode}", + tag=f"dev-compat-{index}", + description="Исторические данные", + city="Москва", + data_schema={}, + draft=False, + projects_availability="all_users", + solution_storage_mode=mode, + datetime_registration_ends=now, + datetime_started=now, + datetime_finished=now, + ) + ) + legacy, program = programs + project = Project( + leader=captain, + name="Старая заявка-проект", + description="Не удалять", + draft=False, + is_public=False, + ) + Project.objects.bulk_create([project]) + apps.get_model("partner_programs", "PartnerProgramProject").objects.create( + partner_program_id=legacy.pk, + project_id=project.pk, + submitted=True, + datetime_submitted=now, + ) + apps.get_model("partner_programs", "PartnerProgramUserProfile").objects.create( + partner_program_id=legacy.pk, + user_id=captain.pk, + project_id=project.pk, + partner_program_data={"keep": [0, False]}, + ) + solution_model = apps.get_model("partner_programs", "ProgramSolution") + payload = {"material": "Снимок без переинтерпретации", "zero": 0, "false": False} + solution = solution_model.objects.create( + program_id=program.pk, + captain_id=captain.pk, + name="Сданное решение", + status="submitted", + submitted_at=now, + submission_snapshot=payload, + ) + solution_model.objects.create(program_id=program.pk, captain_id=member.pk) + apps.get_model("partner_programs", "ProgramSolutionMember").objects.create( + solution_id=solution.pk, user_id=captain.pk, role="Капитан" + ) + field_model = apps.get_model("partner_programs", "PartnerProgramField") + for kind, value in [("number", 0), ("checkbox", False)]: + field = field_model.objects.create( + partner_program_id=program.pk, + name=kind, + field_type=kind, + purpose="other", + label=kind, + is_required=False, + ) + apps.get_model("partner_programs", "ProgramSolutionAnswer").objects.create( + solution_id=solution.pk, field_id=field.pk, value=value + ) + apps.get_model("partner_programs", "SolutionInvitation").objects.create( + solution_id=solution.pk, + recipient_id=member.pk, + role="Дизайнер", + status="declined", + ) + if branch == SOLUTION: + archived = solution_model.objects.create( + program_id=program.pk, + captain_id=captain.pk, + name="Архив", + version=2, + status="submitted", + submitted_at=now, + submission_snapshot=payload, + archived_at=now, + archived_by_id=captain.pk, + archive_reason="detached", + archive_snapshot=payload, + ) + apps.get_model( + "partner_programs", "SolutionAdministrativeChange" + ).objects.create( + solution_id=archived.pk, + actor_id=captain.pk, + actor_name="Организатор", + action="archive", + before_version=1, + after_version=2, + was_submitted=True, + before_snapshot=payload, + after_snapshot=payload, + archived_at=now, + archive_reason="detached", + archive_snapshot=payload, + ) + apps.get_model( + "partner_programs", "SolutionRegistrationRemoval" + ).objects.create( + program_id=program.pk, + actor_id=captain.pk, + actor_name="Организатор", + registrations=[{"registration_id": 999, "user": {"id": member.pk}}], + deleted_count=1, + ) + for kind, value in [("int", "0"), ("bool", "False")]: + criterion = apps.get_model("project_rates", "Criteria").objects.create( + partner_program_id=program.pk, name=kind, type=kind + ) + apps.get_model("project_rates", "SolutionScore").objects.create( + solution_id=solution.pk, + criteria_id=criterion.pk, + user_id=captain.pk, + value=value, + ) + batch_model = apps.get_model("partner_programs", "ProgramCertificateBatch") + batches = [ + batch_model.objects.create( + program_id=legacy.pk, + snapshot={"version": index, "keep": False}, + issue_at=now, + public_base_url="https://local.test", + ) + for index in [1, 2] + ] + certificate_model = apps.get_model("partner_programs", "ProgramCertificate") + options = dict(rendered_batch_id=batches[0].pk) if branch == CERTIFICATE else {} + pdf = certificate_model.objects.create( + program_id=legacy.pk, + user_id=captain.pk, + batch_id=batches[1].pk, + name="Старая версия PDF", + email=captain.email, + pdf=b"%PDF-1.4\noriginal-version\n%%EOF", + status="sent", + **options, + ) + pending = certificate_model.objects.create( + program_id=legacy.pk, + user_id=member.pk, + batch_id=batches[1].pk, + name="Без PDF", + email=member.email, + ) + return ( + pdf.pk, + pending.pk, + batches[0].pk if branch == CERTIFICATE else batches[1].pk, + ) + + def assert_order_preserves_data(self, branch): + try: + apps = self.before(branch) + pdf, pending, expected_batch = self.seed(apps, branch) + labels = [ + "PartnerProgram", + "PartnerProgramProject", + "PartnerProgramUserProfile", + "ProgramSolution", + "ProgramSolutionMember", + "ProgramSolutionAnswer", + "SolutionInvitation", + "PartnerProgramField", + "ProgramCertificateBatch", + "ProgramCertificate", + ] + if branch == SOLUTION: + labels += ["SolutionAdministrativeChange", "SolutionRegistrationRemoval"] + before = {} + for label in labels: + model = apps.get_model("partner_programs", label) + fields = [field.attname for field in model._meta.concrete_fields] + before[label] = fields, self.snapshot(model, fields) + projects = json.dumps( + plain(list(Project.objects.order_by("pk").values())), sort_keys=True + ) + scores = ( + self.snapshot(apps.get_model("project_rates", "SolutionScore")) + if branch == SOLUTION + else None + ) + executor = MigrationExecutor(connection) + executor.migrate(self.latest) + after_apps = executor.loader.project_state(self.latest).apps + for label, (fields, rows) in before.items(): + self.assertEqual( + self.snapshot( + after_apps.get_model("partner_programs", label), fields + ), + rows, + label, + ) + self.assertEqual( + json.dumps( + plain(list(Project.objects.order_by("pk").values())), sort_keys=True + ), + projects, + ) + if scores is not None: + self.assertEqual( + self.snapshot(after_apps.get_model("project_rates", "SolutionScore")), + scores, + ) + model = after_apps.get_model("partner_programs", "ProgramCertificate") + self.assertEqual(model.objects.get(pk=pdf).rendered_batch_id, expected_batch) + self.assertIsNone(model.objects.get(pk=pending).rendered_batch_id) + self.assertEqual(MigrationLoader(connection).detect_conflicts(), {}) + finally: + MigrationExecutor(connection).migrate(self.latest) + + def test_solution_first_preserves_projects_snapshots_scores_and_history(self): + self.assert_order_preserves_data(SOLUTION) + + def test_certificate_first_preserves_original_pdf_batch_and_solutions(self): + self.assert_order_preserves_data(CERTIFICATE) diff --git a/partner_programs/tests/test_solution_entrypoints.py b/partner_programs/tests/test_solution_entrypoints.py new file mode 100644 index 00000000..16d394b3 --- /dev/null +++ b/partner_programs/tests/test_solution_entrypoints.py @@ -0,0 +1,167 @@ +from unittest.mock import patch + +from django.db import transaction +from django.test import TestCase, override_settings +from django.utils import timezone + +from partner_programs import selectors +from partner_programs.services.analytics import ( + _get_participant_metrics, + participants_without_team_rows, +) +from partner_programs.solution_api import create_solution +from partner_programs.solution_models import ProgramSolutionMember, SolutionInvitation +from partner_programs.tests.helpers import create_user, create_program_member +from partner_programs.tests.test_solution_forms import FormFixture +from partner_programs.tests.test_solution_foundation import solution_program +from project_rates import solution_evaluation +from project_rates.solution_models import SolutionScore +from project_rates.tests.test_solution_evaluation import EvaluationFixture +from vacancy.mapping import get_link, create_text_for_email + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionParticipantEntrypointsTests(FormFixture, TestCase): + def setUp(self): + self.seed() + self.accepted, self.pending, self.other = [create_user() for _ in range(3)] + for user in [self.accepted, self.pending, self.other]: + create_program_member(self.program, user=user) + ProgramSolutionMember.objects.create(solution=self.solution, user=self.accepted) + SolutionInvitation.objects.create( + solution=self.solution, recipient=self.pending, role="Аналитик" + ) + + def test_mail_selectors_and_attention_list_agree_on_accepted_team_and_pending(self): + expected = {self.pending.pk, self.other.pk} + self.assertEqual( + set( + selectors.program_participants_without_project( + self.program.pk + ).values_list("pk", flat=True) + ), + expected, + ) + self.assertEqual( + set( + selectors.program_participants_without_project_registered_on( + self.program.pk, timezone.localdate() + ).values_list("pk", flat=True) + ), + expected, + ) + self.assertEqual( + set( + participants_without_team_rows(self.program.pk).values_list( + "user_id", flat=True + ) + ), + expected, + ) + metrics = _get_participant_metrics(self.program.pk) + self.assertEqual(metrics["with_team"], 2) + self.assertEqual(metrics["without_team"], 2) + self.assertEqual(metrics["project_creators"], 1) + self.assertEqual( + set( + selectors.program_participants_with_unsubmitted_project( + self.program.pk + ).values_list("pk", flat=True) + ), + {self.captain.pk, self.accepted.pk}, + ) + + def test_team_in_another_program_does_not_remove_participant_from_current_list(self): + other = solution_program(is_competitive=True) + create_program_member(other, user=self.other) + create_solution(other.pk, self.other, {}) + self.assertIn( + self.other.pk, + selectors.program_participants_without_project(self.program.pk).values_list( + "pk", flat=True + ), + ) + + def test_legacy_destructive_handlers_cannot_remove_solution_registration_or_use_solution_as_link( + self, + ): + from partner_programs.models import PartnerProgramUserProfile + from partner_programs.services.participant_removal import ( + preview_removal, + remove_participants, + RemovalConflict, + ) + from partner_programs.services.management_project_actions import ( + load_links, + ActionConflict, + ) + + profile = PartnerProgramUserProfile.objects.get( + partner_program=self.program, user=self.captain + ) + with self.assertRaises(RemovalConflict): + preview_removal(self.program.pk, self.manager, [profile.pk]) + with self.assertRaises(RemovalConflict): + remove_participants(self.program.pk, self.manager, [profile.pk], "unused") + with self.assertRaises(ActionConflict): + load_links(self.program, [self.solution.pk]) + self.assertTrue(PartnerProgramUserProfile.objects.filter(pk=profile.pk).exists()) + self.assertTrue( + ProgramSolutionMember.objects.filter( + solution=self.solution, user=self.captain + ).exists() + ) + + +@override_settings( + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], + EXPERT_WORKSPACE_FRONTEND_URL="https://dev.procollab.ru/", +) +class SolutionRatingNotificationTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + + @patch("project_rates.solution_notifications.send_email.delay") + def test_success_queues_captain_notice_after_commit_with_real_program_and_solution( + self, send + ): + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.rate() + send.assert_not_called() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(callbacks), 1) + send.assert_called_once() + payload = send.call_args.args[0] + self.assertEqual(payload["message_type"], "solution_rated") + self.assertEqual(payload["user_id"], self.captain.pk) + self.assertEqual(payload["program_id"], self.program.pk) + self.assertEqual(payload["solution_id"], self.solution.pk) + self.assertNotIn("project_id", payload) + self.assertEqual( + get_link(payload), + f"https://dev.procollab.ru/office/program/{self.program.pk}/solutions/{self.solution.pk}", + ) + self.assertIn(self.solution.name, create_text_for_email(payload)) + self.assertNotIn("scores", payload) + + @patch("project_rates.solution_notifications.send_email.delay") + def test_rollback_or_invalid_score_does_not_queue_notice(self, send): + with self.captureOnCommitCallbacks(execute=True): + with self.assertRaises(RuntimeError): + with transaction.atomic(): + solution_evaluation.rate_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.expert, + **self.scores(), + ) + raise RuntimeError("Rollback") + self.assertFalse(SolutionScore.objects.exists()) + send.assert_not_called() + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.rate( + scores=[dict(criterion_id=self.criteria[0].pk, value="NaN")] + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(callbacks, []) + send.assert_not_called() diff --git a/partner_programs/tests/test_solution_expert_reminders.py b/partner_programs/tests/test_solution_expert_reminders.py new file mode 100644 index 00000000..f20f9ed4 --- /dev/null +++ b/partner_programs/tests/test_solution_expert_reminders.py @@ -0,0 +1,554 @@ +import copy +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest import skipUnless +from unittest.mock import patch + +from django.core import mail, signing +from django.db import close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + ProgramAttentionReminder, + ProgramAttentionReminderBatch, +) +from partner_programs.services.attention_actions import ( + SALT, + confirm_reminder, + deliver_reminder, + preview_reminder, +) +from partner_programs.services.solution_attention import SOLUTION_AWAITING +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_models import ProgramSolution +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_user, +) +from partner_programs.tests.test_solution_forms import call +from project_rates.models import Criteria, ProjectExpertAssignment +from project_rates.solution_evaluation import snapshot_name +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.test_solution_evaluation import EvaluationFixture +from project_rates.tests.helpers import create_rate_expert +from projects.models import Project + + +class ReminderFixture(EvaluationFixture): + def seed_reminder(self): + self.seed_evaluation() + self.reminder_url = ( + f"/programs/{self.program.pk}/management/solution-analytics/" + "attention/solutions-awaiting-evaluation/" + ) + + def selection(self, ids=None, *, search="", excluded=None): + return dict( + search=search, + selection=dict( + mode="all" if ids is None else "selected", + ids=ids or [], + excluded_ids=excluded or [], + ), + ) + + def preview(self, payload=None): + response = call( + self.manager, + "post", + self.reminder_url + "reminder-preview/", + payload or self.selection(), + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assert_schema("SolutionReminderPreview", response.data) + return response.data + + def confirm(self, preview): + response = call( + self.manager, + "post", + self.reminder_url + "reminders/", + dict(token=preview["token"]), + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assert_schema("SolutionReminderReceipt", response.data) + return response.data + + def assign(self, solution, expert=None): + return SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=solution, + expert=(expert or self.expert).expert, + ) + + def complete(self, solution=None, expert=None): + for criterion, value in zip(self.criteria, ["0", "False", ""]): + SolutionScore.objects.create( + solution=solution or self.solution, + criteria=criterion, + user=expert or self.expert, + value=value, + ) + + +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], + EXPERT_WORKSPACE_FRONTEND_URL="https://procollab.local", +) +class SolutionExpertRemindersTests(ReminderFixture, TestCase): + def setUp(self): + self.seed_reminder() + + def test_preview_is_private_read_only_scoped_and_deduplicates_real_expert(self): + second = self.another_solution() + self.assign(second) + self.another_solution(submitted=False) + unassigned = self.another_solution() + other = create_partner_program(solution_storage_mode="solution") + foreign_snapshot = copy.deepcopy(self.solution.submission_snapshot) + foreign_snapshot["program_id"] = other.pk + foreign = ProgramSolution.objects.create( + program=other, + captain=self.captain, + status="submitted", + submitted_at=timezone.now(), + submission_snapshot=foreign_snapshot, + ) + + result = self.preview() + + self.assertEqual(result["selected_count"], 3) + self.assertEqual(result["recipient_count"], 1) + self.assertEqual(result["without_assignments_count"], 1) + self.assertEqual(result["unavailable_count"], 0) + self.assertEqual(result["recipients"][0]["user_id"], self.expert.pk) + self.assertNotIn("email", result["recipients"][0]) + token = signing.loads(result["token"], salt=SALT) + self.assertCountEqual( + token["source_ids"], [self.solution.pk, second.pk, unassigned.pk] + ) + self.assertNotIn(foreign.pk, token["source_ids"]) + self.assertFalse(ProgramAttentionReminderBatch.objects.exists()) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_search_uses_snapshot_and_selection_excludes_other_found_rows(self): + name = snapshot_name(self.solution) + ProgramSolution.objects.filter(pk=self.solution.pk).update(name="Другое имя") + second = self.another_solution() + self.assign(second) + result = self.preview(self.selection(search=name)) + self.assertEqual(result["selected_count"], 1) + self.assertIn(f"«{name}»", result["preview_text"]) + self.assertNotIn("Другое имя", result["preview_text"]) + result = self.preview(self.selection(excluded=[self.solution.pk])) + self.assertEqual(result["selected_count"], 1) + self.assertIn(f"«{snapshot_name(second)}»", result["preview_text"]) + result = self.preview(self.selection([self.solution.pk])) + self.assertEqual(result["selected_count"], 1) + + def test_all_selection_is_not_limited_to_first_twenty_solutions(self): + for _ in range(25): + self.assign(self.another_solution()) + result = self.preview(self.selection(excluded=[self.solution.pk])) + self.assertEqual(result["selected_count"], 25) + self.assertEqual(result["recipient_count"], 1) + token = signing.loads(result["token"], salt=SALT) + self.assertEqual(len(token["source_ids"]), 25) + self.assertNotIn(self.solution.pk, token["source_ids"]) + + def test_recipient_preview_is_limited_but_approved_count_is_complete(self): + self.program.required_evaluators = 21 + self.program.save(update_fields=["required_evaluators"]) + for _ in range(20): + self.assign(self.solution, create_rate_expert(program=self.program)) + result = self.preview() + self.assertEqual(result["recipient_count"], 21) + self.assertEqual(len(result["recipients"]), 20) + self.assertEqual(len(signing.loads(result["token"], salt=SALT)["targets"]), 21) + + def test_other_program_missing_program_and_cross_program_token_are_rejected(self): + other = create_partner_program( + solution_storage_mode="solution", + is_distributed_evaluation=True, + is_competitive=True, + ) + url = self.reminder_url.replace(f"/{self.program.pk}/", f"/{other.pk}/") + response = call(self.manager, "post", url + "reminder-preview/", self.selection()) + self.assertEqual(response.status_code, 403) + self.assert_schema("Error", response.data) + other.managers.add(self.manager) + preview = self.preview() + response = call( + self.manager, "post", url + "reminders/", dict(token=preview["token"]) + ) + self.assertEqual(response.status_code, 400) + self.assert_schema("Error", response.data) + missing_url = self.reminder_url.replace(f"/{self.program.pk}/", f"/{MAX_ID}/") + response = call( + self.manager, "post", missing_url + "reminder-preview/", self.selection() + ) + self.assertEqual(response.status_code, 404) + self.assert_schema("Error", response.data) + + def test_full_zero_false_and_empty_comment_are_completed(self): + self.complete() + result = self.preview() + self.assertEqual(result["selected_count"], 0) + self.assertEqual(result["recipient_count"], 0) + self.assertIsNone(result["token"]) + + def test_partial_or_invalid_scores_keep_assignment_unfinished(self): + score = SolutionScore.objects.create( + criteria=self.criteria[0], user=self.expert, solution=self.solution, value="0" + ) + self.assertEqual(self.preview()["recipient_count"], 1) + self.complete(expert=self.second) + SolutionScore.objects.filter(pk=score.pk).update(value="NaN") + self.assertEqual(self.preview()["recipient_count"], 1) + + def test_quota_uses_analytics_state_and_only_pending_expert_is_targeted(self): + self.program.required_evaluators = 2 + self.program.save(update_fields=["required_evaluators"]) + self.assign(self.solution, self.second) + self.complete() + result = self.preview() + self.assertEqual(result["recipient_count"], 1) + self.assertEqual(result["recipients"][0]["user_id"], self.second.pk) + self.program.required_evaluators = 1 + self.program.save(update_fields=["required_evaluators"]) + self.assertEqual(self.preview()["selected_count"], 0) + + def test_no_criteria_does_not_turn_assignment_into_completed(self): + Criteria.objects.filter(partner_program=self.program).delete() + self.assertEqual(self.preview()["recipient_count"], 1) + + def test_unassigned_missing_email_and_inactive_are_distinct(self): + self.assignment.delete() + unassigned = self.preview() + self.assertEqual(unassigned["without_assignments_count"], 1) + self.assertEqual(unassigned["unavailable_count"], 0) + self.assign(self.solution) + type(self.expert).objects.filter(pk=self.expert.pk).update(email="") + missing = self.preview() + self.assertEqual(missing["without_assignments_count"], 0) + self.assertEqual(missing["unavailable_count"], 1) + self.assertIsNone(missing["token"]) + type(self.expert).objects.filter(pk=self.expert.pk).update(is_active=False) + inactive = self.preview() + self.assertEqual(inactive["without_assignments_count"], 1) + self.assertEqual(inactive["unavailable_count"], 0) + + def test_direct_api_program_permissions_modes_and_errors(self): + for suffix in ["reminder-preview/", "reminders/"]: + for actor, status in [ + (self.captain, 403), + (self.expert, 403), + (create_user(), 403), + ]: + response = call(actor, "post", self.reminder_url + suffix, {}) + self.assertEqual(response.status_code, status) + self.assert_schema("Error", response.data) + response = APIClient().post(self.reminder_url + suffix, {}, format="json") + self.assertEqual(response.status_code, 401) + self.assert_schema("Error", response.data) + staff = create_user(is_staff=True) + self.assertEqual( + call( + staff, "post", self.reminder_url + "reminder-preview/", self.selection() + ).status_code, + 200, + ) + legacy = create_partner_program() + legacy.managers.add(self.manager) + url = self.reminder_url.replace(f"/{self.program.pk}/", f"/{legacy.pk}/") + self.assertEqual( + call(self.manager, "post", url + "reminder-preview/", self.selection()).data[ + "code" + ], + "solution_mode_unavailable", + ) + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + response = call( + self.manager, + "post", + self.reminder_url + "reminder-preview/", + self.selection(), + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "reminders_not_applicable") + self.program.is_competitive = False + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_competitive", "is_distributed_evaluation"]) + self.assertEqual( + call( + self.manager, + "post", + self.reminder_url + "reminder-preview/", + self.selection(), + ).status_code, + 409, + ) + + def test_strict_selection_safe_ids_query_and_max_found_validation(self): + invalid = [ + {"selection": {"mode": "selected", "ids": []}}, + {"selection": {"mode": "all", "ids": [self.solution.pk]}}, + {"selection": {"mode": "selected", "ids": [0]}}, + {"selection": {"mode": "selected", "ids": [MAX_ID + 1]}}, + {"selection": {"mode": "selected", "ids": [1] * 10001}}, + {"selection": {"mode": "all", "unknown": True}}, + {"selection": {"mode": "all"}, "project_id": self.solution.pk}, + ] + for payload in invalid: + response = call( + self.manager, "post", self.reminder_url + "reminder-preview/", payload + ) + self.assertEqual(response.status_code, 400, response.data) + self.assert_schema("Error", response.data) + response = call( + self.manager, + "post", + self.reminder_url + "reminder-preview/?unknown=1", + self.selection(), + ) + self.assertEqual(response.status_code, 400) + with patch("partner_programs.services.solution_attention.MAX_SELECTION", 0): + response = call( + self.manager, + "post", + self.reminder_url + "reminder-preview/", + self.selection(), + ) + self.assertEqual(response.status_code, 400) + + def test_token_tampering_expiry_actor_and_other_category_are_rejected(self): + preview = self.preview() + for token in [preview["token"] + "tamper", "not-signed"]: + self.assertEqual( + call( + self.manager, + "post", + self.reminder_url + "reminders/", + dict(token=token), + ).status_code, + 400, + ) + with patch( + "partner_programs.services.attention_actions.signing.loads", + side_effect=signing.SignatureExpired, + ): + self.assertEqual( + call( + self.manager, + "post", + self.reminder_url + "reminders/", + dict(token=preview["token"]), + ).status_code, + 400, + ) + other = create_user() + self.program.managers.add(other) + self.assertEqual( + call( + other, + "post", + self.reminder_url + "reminders/", + dict(token=preview["token"]), + ).status_code, + 400, + ) + user = create_user() + create_program_member(self.program, user=user) + other_preview = preview_reminder( + self.program, + "participants-without-team", + self.manager, + self.selection([user.pk]), + ) + self.assertEqual( + call( + self.manager, + "post", + self.reminder_url + "reminders/", + dict(token=other_preview["token"]), + ).status_code, + 400, + ) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_confirmation_rechecks_completed_and_replaced_assignment(self): + preview = self.preview() + self.assignment.delete() + self.assign(self.solution) + self.assertEqual(self.confirm(preview), {"created_count": 0, "skipped_count": 1}) + fresh = self.preview() + self.complete() + self.assertEqual(self.confirm(fresh), {"created_count": 0, "skipped_count": 1}) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_confirmation_is_durable_idempotent_after_commit_and_does_not_add_targets( + self, + ): + self.program.required_evaluators = 2 + self.program.save(update_fields=["required_evaluators"]) + preview = self.preview() + self.assign(self.solution, self.second) + with patch("partner_programs.services.attention_actions.queue_batch") as queue: + with self.captureOnCommitCallbacks(execute=True): + result = self.confirm(preview) + queue.assert_not_called() + queue.assert_called_once() + self.assertEqual(self.confirm(preview), result) + queue.assert_called_once() + self.assertEqual(result, {"created_count": 1, "skipped_count": 0}) + notice = ProgramAttentionReminder.objects.get() + self.assertEqual(notice.recipient_id, self.expert.pk) + self.assertEqual(notice.source_ids, [self.solution.pk]) + self.assertEqual(notice.assignment_ids, [self.assignment.pk]) + + def test_rollback_removes_notice_and_does_not_enqueue(self): + preview = self.preview() + with patch("partner_programs.services.attention_actions.queue_batch") as queue: + with self.captureOnCommitCallbacks(execute=True): + with transaction.atomic(): + self.confirm(preview) + transaction.set_rollback(True) + queue.assert_not_called() + self.assertFalse(ProgramAttentionReminderBatch.objects.exists()) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_platform_notice_is_owned_by_the_actual_assigned_expert(self): + self.confirm(self.preview()) + notice = ProgramAttentionReminder.objects.get() + feed = call(self.expert, "get", "/core/notifications/") + self.assertEqual(feed.status_code, 200) + self.assertEqual( + len( + [ + row + for row in feed.data["results"] + if row["kind"] == "program_attention" + ] + ), + 1, + ) + read_url = f"/core/notifications/program_attention:{notice.pk}/read/" + self.assertEqual(call(self.manager, "post", read_url, {}).status_code, 404) + self.assertEqual(call(self.expert, "post", read_url, {}).status_code, 200) + + def test_delivery_uses_only_remaining_approved_work_and_real_solution_route(self): + second = self.another_solution() + other_assignment = self.assign(second) + self.confirm(self.preview()) + notice = ProgramAttentionReminder.objects.get() + other_assignment.delete() + self.assign(second) + deliver_reminder(notice.pk) + deliver_reminder(notice.pk) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [self.expert.email]) + self.assertIn(f"«{snapshot_name(self.solution)}»", mail.outbox[0].body) + self.assertNotIn(f"«{snapshot_name(second)}»", mail.outbox[0].body) + self.assertIn( + f"https://procollab.local/office/program/{self.program.pk}/solutions-rating", + mail.outbox[0].body, + ) + self.assertNotIn("/projects-rating", mail.outbox[0].body) + notice.refresh_from_db() + self.assertEqual(notice.delivery_status, "sent") + self.assertEqual(notice.attempts, 1) + + def test_delivery_cancels_when_expert_finishes_or_leaves_program(self): + self.confirm(self.preview()) + notice = ProgramAttentionReminder.objects.get() + self.expert.expert.programs.remove(self.program) + deliver_reminder(notice.pk) + notice.refresh_from_db() + self.assertEqual(notice.delivery_status, "cancelled") + self.assertEqual(len(mail.outbox), 0) + + def test_delivery_cancels_after_completion_or_mode_switch(self): + self.confirm(self.preview()) + first = ProgramAttentionReminder.objects.get() + self.complete() + deliver_reminder(first.pk) + first.refresh_from_db() + self.assertEqual(first.delivery_status, "cancelled") + second = self.another_solution() + self.assign(second) + self.confirm(self.preview()) + last = ProgramAttentionReminder.objects.exclude(pk=first.pk).get() + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + deliver_reminder(last.pk) + last.refresh_from_db() + self.assertEqual(last.delivery_status, "cancelled") + self.assertEqual(len(mail.outbox), 0) + + def test_colliding_project_identity_and_legacy_assignments_are_not_targets(self): + # Историческая проектная заявка могла существовать до включения solution mode. + project = Project( + id=self.solution.pk, + leader=create_user(), + name="Посторонний проект", + description="Описание", + draft=False, + is_public=False, + ) + Project.objects.bulk_create([project]) + create_program_project(self.program, project=project, submitted=True) + ProjectExpertAssignment.objects.bulk_create( + [ + ProjectExpertAssignment( + partner_program=self.program, + project=project, + expert=self.second.expert, + ) + ] + ) + result = self.preview() + self.assertEqual(result["recipient_count"], 1) + self.assertEqual(result["recipients"][0]["user_id"], self.expert.pk) + self.assertNotIn("Посторонний проект", result["preview_text"]) + self.confirm(result) + notice = ProgramAttentionReminder.objects.get() + self.assertEqual(notice.assignment_ids, [self.assignment.pk]) + self.assertEqual(notice.source_ids, [self.solution.pk]) + + +@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL locks") +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionReminderConcurrencyTests(ReminderFixture, TransactionTestCase): + def test_two_confirmations_create_one_batch_notice_and_queue(self): + self.seed_reminder() + preview = preview_reminder( + self.program, SOLUTION_AWAITING, self.manager, self.selection() + ) + barrier = Barrier(2) + + def confirm(): + close_old_connections() + try: + barrier.wait(timeout=10) + return confirm_reminder( + self.program, SOLUTION_AWAITING, self.manager, preview["token"] + ) + finally: + close_old_connections() + + with patch("partner_programs.services.attention_actions.queue_batch") as queue: + with ThreadPoolExecutor(max_workers=2) as workers: + first, second = list(workers.map(lambda _: confirm(), range(2))) + self.assertEqual(first, second) + self.assertEqual(first, {"created_count": 1, "skipped_count": 0}) + self.assertEqual(ProgramAttentionReminderBatch.objects.count(), 1) + self.assertEqual(ProgramAttentionReminder.objects.count(), 1) + queue.assert_called_once() diff --git a/partner_programs/tests/test_solution_field_filters.py b/partner_programs/tests/test_solution_field_filters.py new file mode 100644 index 00000000..204edbe8 --- /dev/null +++ b/partner_programs/tests/test_solution_field_filters.py @@ -0,0 +1,352 @@ +import copy +from io import BytesIO +from urllib.parse import parse_qs, urlsplit + +from django.db import connection +from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from files.models import UserFile +from partner_programs import solution_forms +from partner_programs.solution_models import ProgramSolution, ProgramSolutionAnswer +from partner_programs.solution_schema import form_data +from partner_programs.tests.helpers import create_user +from partner_programs.tests.test_solution_forms import call, new_field +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionFieldFilterTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.root = f"/programs/{self.program.pk}/management/solution-analytics/" + + def page(self, **query): + response = call(self.manager, "get", self.root + "solutions/", query) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assert_schema("ManagerSolutions", response.data) + return response.data + + def ids(self, **query): + return [ + row["application"]["solution_id"] for row in self.page(**query)["results"] + ] + + def book(self, **query): + response = call( + self.manager, "get", self.root + "export/", {"kind": "solutions", **query} + ) + self.assertEqual(response.status_code, 200, getattr(response, "data", None)) + self.assertEqual(response["Cache-Control"], "private, no-store") + return load_workbook(BytesIO(response.content), data_only=False) + + def answer(self, solution, kind, value): + if value is None: + # API удаляет ответ при null; в БД value NOT NULL сохраняется. + ProgramSolutionAnswer.objects.filter( + solution=solution, field_id=self.fields[kind]["id"] + ).delete() + return + ProgramSolutionAnswer.objects.update_or_create( + solution=solution, + field_id=self.fields[kind]["id"], + defaults={"value": value}, + ) + + def test_no_field_filter_and_explicit_all_preserve_existing_selection(self): + draft = self.another_solution(submitted=False) + expected = [self.solution.pk, draft.pk] + self.assertEqual(self.ids(), expected) + self.assertEqual(self.ids(answer_state="all"), expected) + self.assertEqual( + self.ids(field_id=self.fields["number"]["id"], answer_state="all"), expected + ) + self.assertEqual( + [row[0].value for row in list(self.book()["Решения"].rows)[1:]], expected + ) + + def test_zero_and_false_are_present_and_absent_answer_is_missing(self): + draft = self.another_solution(submitted=False) + for kind in ("number", "checkbox"): + query = dict(field_id=self.fields[kind]["id"]) + with self.subTest(kind=kind): + self.assertEqual( + self.ids(**query, answer_state="present"), [self.solution.pk] + ) + self.assertEqual(self.ids(**query, answer_state="missing"), [draft.pk]) + self.assertEqual(self.ids(**query, answer_state="not_applicable"), []) + + def test_null_empty_and_whitespace_follow_existing_empty_value(self): + draft = self.another_solution(submitted=False) + for value in (None, "", " \t\n", "Текст"): + self.answer(draft, "textarea", value) + with self.subTest(value=value): + query = dict(field_id=self.fields["textarea"]["id"]) + self.assertEqual( + draft.pk in self.ids(**query, answer_state="present"), + value == "Текст", + ) + self.assertEqual( + draft.pk in self.ids(**query, answer_state="missing"), + value != "Текст", + ) + + def test_new_required_field_is_not_applicable_to_old_submission(self): + draft = self.another_solution(submitted=False) + before = copy.deepcopy(self.solution.submission_snapshot) + solution_forms.save_form( + program_id=self.program.pk, + actor=self.manager, + **self.payload( + fields=[ + *form_data(self.program)["fields"], + new_field("number", required=True, position=50), + ] + ), + ) + added = form_data(self.program)["fields"][-1]["id"] + self.assertEqual( + self.ids(field_id=added, answer_state="not_applicable"), [self.solution.pk] + ) + self.assertEqual(self.ids(field_id=added, answer_state="missing"), [draft.pk]) + ProgramSolutionAnswer.objects.create(solution=draft, field_id=added, value=0) + self.assertEqual(self.ids(field_id=added, answer_state="present"), [draft.pk]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, before) + + def test_historical_snapshot_field_is_selectable_without_current_definition(self): + # Имитируем историческую запись из миграции, не разрешение редактировать сдачу. + snapshot = copy.deepcopy(self.solution.submission_snapshot) + historical = { + **snapshot["form"]["fields"][1], + "id": 9000000, + "label": "Архивное поле", + } + snapshot["form"]["fields"].append(historical) + snapshot["answers"].append( + dict(field_id=historical["id"], value="Архивный ответ") + ) + ProgramSolution.objects.filter(pk=self.solution.pk).update( + submission_snapshot=snapshot + ) + draft = self.another_solution(submitted=False) + self.assertEqual( + self.ids(field_id=historical["id"], answer_state="present"), + [self.solution.pk], + ) + self.assertEqual( + self.ids(field_id=historical["id"], answer_state="not_applicable"), [draft.pk] + ) + book = self.book(field_id=historical["id"], answer_state="present") + self.assertIn( + historical["id"], [row[1].value for row in list(book["Поля формы"].rows)[1:]] + ) + + def test_submitted_answers_and_search_use_snapshot_over_late_live_values(self): + ProgramSolutionAnswer.objects.filter( + solution=self.solution, field_id=self.fields["number"]["id"] + ).update(value="") + ProgramSolution.objects.filter(pk=self.solution.pk).update( + name="Позднее live имя" + ) + name = self.solution.submission_snapshot["name"] + self.assertEqual( + self.ids( + search=name, field_id=self.fields["number"]["id"], answer_state="present" + ), + [self.solution.pk], + ) + self.assertEqual(self.ids(search="Позднее live имя"), []) + + def test_field_filter_intersects_case_submission_attention_and_search(self): + draft = self.another_solution(submitted=False) + self.answer(draft, "number", 0) + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + self.assertEqual( + self.ids( + **query, + case_scope="selected", + case_name="Задача 1", + scope="awaiting_evaluation", + submission="submitted", + ), + [self.solution.pk], + ) + self.assertEqual( + self.ids( + **query, case_scope="without_case", scope="not_submitted", search="Вторая" + ), + [draft.pk], + ) + self.assertEqual( + self.ids(**query, submission="submitted", scope="not_submitted"), [] + ) + + def test_pagination_counts_and_links_preserve_filter_and_export_is_unpaginated(self): + for _ in range(24): + self.answer(self.another_solution(submitted=False), "number", 0) + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + page = self.page(**query, limit=5, offset=20) + self.assertEqual(page["count"], 25) + self.assertEqual(len(page["results"]), 5) + previous = parse_qs(urlsplit(page["previous"]).query) + self.assertEqual(previous["field_id"], [str(query["field_id"])]) + self.assertEqual(previous["answer_state"], ["present"]) + self.assertEqual(previous["offset"], ["15"]) + self.assertIsNone(page["next"]) + self.assertEqual(self.book(**query)["Решения"].max_row, 26) + + def test_filtered_workbook_scopes_every_related_sheet_and_keeps_zero_false_types( + self, + ): + self.another_solution(submitted=False) + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + book = self.book(**query) + self.assertEqual( + set(book.sheetnames), {"Решения", "Команды", "Поля формы", "Ответы решения"} + ) + for sheet in book: + self.assertEqual( + {row[0].value for row in list(sheet.rows)[1:]}, {self.solution.pk} + ) + answers = {row[1].value: row[4] for row in list(book["Ответы решения"].rows)[1:]} + self.assertEqual(answers[self.fields["number"]["id"]].value, 0) + self.assertEqual(answers[self.fields["number"]["id"]].data_type, "n") + self.assertIs(answers[self.fields["checkbox"]["id"]].value, False) + self.assertEqual(answers[self.fields["checkbox"]["id"]].data_type, "b") + + def test_submitted_export_intersects_filter_and_empty_workbook_remains_valid(self): + draft = self.another_solution(submitted=False) + self.answer(draft, "number", 0) + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + book = self.book(kind="submitted", **query) + self.assertEqual(book["Решения"].max_row, 2) + self.assertEqual(book["Решения"]["A2"].value, self.solution.pk) + for query in ( + dict(search="Нет такой работы"), + dict(kind="submitted", submission="not_submitted"), + ): + with self.subTest(query=query): + self.assertTrue(all(sheet.max_row == 1 for sheet in self.book(**query))) + + def test_invalid_field_queries_are_uniform_c00_errors(self): + foreign = self.another_program_field() + for query in ( + dict(answer_state="present"), + dict(answer_state="missing"), + dict(answer_state="not_applicable"), + dict(field_id=0), + dict(field_id=-1), + dict(field_id=1.5), + dict(field_id=9007199254740992), + dict(field_id="NaN"), + dict(field_id=foreign), + dict(field_id=9999999), + dict(field_id=self.fields["number"]["id"], answer_state="answered"), + dict(field_id=""), + dict(field_id="true"), + dict(answer_state=""), + dict(project_id=1), + ): + for suffix in ("solutions/", "export/"): + with self.subTest(query=query, suffix=suffix): + response = call( + self.manager, + "get", + self.root + suffix, + { + **({"kind": "solutions"} if suffix == "export/" else {}), + **query, + }, + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual(response.data["code"], "validation_error") + self.assert_schema("Error", response.data) + + def another_program_field(self): + from partner_programs.tests.test_solution_foundation import solution_program + + return form_data(solution_program())["fields"][0]["id"] + + def test_global_exports_reject_filters_and_accept_previous_default_calls(self): + for kind in ("program", "scores"): + self.book(kind=kind) + for query in ( + dict(search="Работа"), + dict(field_id=self.fields["number"]["id"]), + dict(scope="not_submitted"), + dict(case_scope="without_case"), + ): + with self.subTest(kind=kind, query=query): + response = call( + self.manager, + "get", + self.root + "export/", + dict(kind=kind, **query), + ) + self.assertEqual(response.status_code, 400, response.data) + for query in ( + dict(limit=20), + dict(offset=0), + dict(case_scope="selected", case_name="Чужой кейс"), + ): + response = call( + self.manager, + "get", + self.root + "export/", + dict(kind="solutions", **query), + ) + self.assertEqual(response.status_code, 400, response.data) + + def test_filtered_endpoints_keep_real_permissions_before_field_disclosure(self): + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + for suffix in ("solutions/", "export/"): + for actor in (self.captain, self.expert, create_user()): + response = call(actor, "get", self.root + suffix, query) + self.assertEqual(response.status_code, 403, response.data) + self.assertEqual(APIClient().get(self.root + suffix, query).status_code, 401) + staff = call( + create_user(is_staff=True), + "get", + self.root + suffix, + {**query, **({"kind": "solutions"} if suffix == "export/" else {})}, + ) + self.assertEqual(staff.status_code, 200) + + def test_noncompetitive_repository_field_needs_no_submission(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + draft = self.another_solution(submitted=False) + self.answer(draft, "url", "https://example.com/repository") + query = dict(field_id=self.fields["url"]["id"], answer_state="present") + page = self.page(**query) + self.assertFalse(page["submission_applicable"]) + self.assertEqual(page["results"][0]["evaluation_status"], "not_applicable") + self.assertEqual(self.ids(**query), [draft.pk]) + self.assertEqual(self.book(**query)["Решения"]["H2"].value, "Не требуется") + draft.refresh_from_db() + self.assertEqual(draft.status, "draft") + + def test_filtered_reads_are_readonly_with_constant_query_count(self): + query = dict(field_id=self.fields["number"]["id"], answer_state="present") + before = list(ProgramSolution.objects.values()) + answers = list(ProgramSolutionAnswer.objects.values()) + files = UserFile.objects.count() + with CaptureQueriesContext(connection) as list_small: + self.page(**query) + with CaptureQueriesContext(connection) as book_small: + self.book(**query) + self.assertEqual(list(ProgramSolution.objects.values()), before) + self.assertEqual(list(ProgramSolutionAnswer.objects.values()), answers) + self.assertEqual(UserFile.objects.count(), files) + for _ in range(8): + self.answer(self.another_solution(submitted=False), "number", 0) + with CaptureQueriesContext(connection) as list_large: + self.assertEqual(self.page(**query)["count"], 9) + with CaptureQueriesContext(connection) as book_large: + self.assertEqual(self.book(**query)["Решения"].max_row, 10) + self.assertEqual(len(list_small), len(list_large)) + self.assertEqual(len(book_small), len(book_large)) diff --git a/partner_programs/tests/test_solution_forms.py b/partner_programs/tests/test_solution_forms.py new file mode 100644 index 00000000..681c34e8 --- /dev/null +++ b/partner_programs/tests/test_solution_forms.py @@ -0,0 +1,428 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +from django.core.exceptions import ValidationError +from django.db import close_old_connections +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs import solution_forms +from partner_programs.solution_api import create_solution, solution_data +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ProgramSolutionAnswer +from partner_programs.solution_schema import ( + form_data, + template_fields, + VALIDATION_DEFAULTS, +) +from partner_programs.tests.helpers import create_user, create_program_member +from partner_programs.tests.test_solution_foundation import solution_program + + +def call(actor, method, url, data=None): + client = APIClient() + client.force_authenticate(actor) + return getattr(client, method)(url, {} if data is None else data, format="json") + + +def new_field(kind="textarea", purpose="other", **overrides): + result = dict( + id=None, + type=kind, + purpose=purpose, + label="Новое поле", + help_text="", + required=False, + position=1, + options=[], + validation=copy.deepcopy(VALIDATION_DEFAULTS), + ) + result.update(overrides) + return result + + +class FormFixture: + def seed(self): + self.program = solution_program(is_competitive=True) + self.manager = create_user() + self.program.managers.add(self.manager) + self.captain = create_user() + create_program_member(self.program, user=self.captain) + self.solution, _ = create_solution(self.program.pk, self.captain, {}) + self.root = f"/programs/{self.program.pk}/management/solution-form/" + self.read_root = f"/programs/{self.program.pk}/solution-form/" + + def payload(self, **overrides): + form = form_data(self.program) + result = dict( + expected_revision=form["revision"], + template=form["template"], + fields=form["fields"], + ) + result.update(overrides) + return copy.deepcopy(result) + + def put(self, payload=None, actor=None): + return call(actor or self.manager, "put", self.root, payload or self.payload()) + + def answer(self, field_id, value): + return ProgramSolutionAnswer.objects.create( + solution=self.solution, field_id=field_id, value=value + ) + + def assert_error(self, response, code, status=409): + self.assertEqual(response.status_code, status, response.data) + self.assertEqual(response.data["code"], code) + self.assertEqual(set(response.data), {"code", "detail", "errors", "meta"}) + + +class SolutionFormTests(FormFixture, TestCase): + def setUp(self): + self.seed() + + def test_templates_match_contract_and_are_independent_copies(self): + response = call(self.manager, "get", "/solution-form-templates/") + self.assertEqual(response.status_code, 200) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + expected = spec["paths"]["/solution-form-templates/"]["get"]["responses"]["200"][ + "content" + ]["application/json"]["examples"]["templates"]["value"] + self.assertEqual(response.data, expected) + response.data["results"][0]["fields"][0]["options"].append("Изменение") + self.assertEqual(template_fields()["results"][0]["fields"][0]["options"], []) + self.assertEqual( + call(self.manager, "get", "/solution-form-templates/?unknown=1").status_code, + 400, + ) + self.assertEqual(APIClient().get("/solution-form-templates/").status_code, 401) + + def test_reads_and_management_match_openapi_with_program_scoped_permissions(self): + outsider = create_user() + other_manager = create_user() + other_program = solution_program() + other_program.managers.add(other_manager) + for actor in (outsider, other_manager): + self.assert_error( + call(actor, "get", self.read_root), "permission_denied", 403 + ) + self.assert_error(call(actor, "get", self.root), "permission_denied", 403) + self.assertEqual(call(self.captain, "get", self.read_root).status_code, 200) + self.assert_error(call(self.captain, "get", self.root), "permission_denied", 403) + staff = create_user(is_staff=True) + self.assertEqual(call(staff, "get", self.root).status_code, 200) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + from jsonschema import Draft202012Validator + + for schema, url in (("Form", self.read_root), ("FormManagement", self.root)): + Draft202012Validator( + { + "components": spec["components"], + "$ref": f"#/components/schemas/{schema}", + } + ).validate(call(self.manager, "get", url).data) + + def test_before_configuration_does_not_choose_or_copy_template_automatically(self): + self.program.fields.all().delete() + self.program.solution_template = None + self.program.save() + response = call(self.captain, "get", self.read_root) + self.assertIsNone(response.data["template"]) + self.assertEqual(response.data["fields"], []) + self.assertFalse(response.data["is_configured"]) + + def test_save_each_template_copies_definitions_and_normalizes_order(self): + for template in template_fields()["results"]: + with self.subTest(template=template["key"]): + rows = copy.deepcopy(template["fields"]) + rows[0]["options"] = ["Трек A | B", "Трек C"] + for index, row in enumerate(rows): + row["position"] = index * 10 + response = self.put( + self.payload(template=template["key"], fields=list(reversed(rows))) + ) + self.assertEqual(response.status_code, 200, response.data) + form = response.data["form"] + self.assertEqual(form["template"], template["key"]) + self.assertTrue(form["is_configured"]) + self.assertEqual( + [field["position"] for field in form["fields"]], + list(range(len(rows))), + ) + self.assertTrue(all(field["id"] for field in form["fields"])) + self.assertEqual(form["fields"][0]["options"], ["Трек A | B", "Трек C"]) + self.assertEqual( + self.program.fields.get(name="case").get_options_list(), + ["Трек A | B", "Трек C"], + ) + self.program.refresh_from_db() + + def test_stale_form_does_not_write_any_part(self): + payload = self.payload() + first = self.put( + {**payload, "fields": [{**payload["fields"][0], "label": "Изменено"}]} + ) + self.assertEqual(first.status_code, 200) + self.assert_error(self.put(payload), "form_revision_conflict") + self.assertEqual(self.program.fields.get().label, "Изменено") + + def test_mode_rollback_blocks_put_but_allows_read(self): + payload = self.payload() + self.program.solution_storage_mode = "legacy_project" + self.program.save() + self.assert_error(self.put(payload), "solution_mode_unavailable") + self.assertEqual(call(self.captain, "get", self.read_root).status_code, 200) + + def test_strict_types_unknown_properties_and_invalid_validation_ranges_are_atomic( + self, + ): + invalid = [ + {"expected_revision": "bad"}, + {"team_id": 1}, + {"fields": [{**self.payload()["fields"][0], "required": "true"}]}, + {"fields": [{**self.payload()["fields"][0], "id": True}]}, + {"fields": [{**self.payload()["fields"][0], "position": -1}]}, + { + "fields": [ + self.payload()["fields"][0], + new_field( + validation={ + **VALIDATION_DEFAULTS, + "min_length": 5, + "max_length": 2, + } + ), + ] + }, + { + "fields": [ + self.payload()["fields"][0], + new_field( + "number", validation={**VALIDATION_DEFAULTS, "minimum": True} + ), + ] + }, + { + "fields": [ + self.payload()["fields"][0], + new_field( + "number", validation={**VALIDATION_DEFAULTS, "max_length": 5} + ), + ] + }, + { + "fields": [ + self.payload()["fields"][0], + new_field( + "file", + validation={ + **VALIDATION_DEFAULTS, + "max_file_size_bytes": 104857601, + }, + ), + ] + }, + { + "fields": [ + self.payload()["fields"][0], + new_field( + "file", + validation={**VALIDATION_DEFAULTS, "allowed_extensions": ["PDF"]}, + ), + ] + }, + ] + before = self.payload() + for changes in invalid: + with self.subTest(changes=changes): + self.assert_error( + self.put({**before, **changes}), "validation_error", 400 + ) + self.assertEqual(self.payload(), before) + + def test_duplicate_unknown_field_ids_and_positions_rejected(self): + case = self.payload()["fields"][0] + other = solution_program().fields.get() + for rows in ( + [case, case], + [{**case, "id": other.pk}], + [case, new_field(position=0)], + ): + self.assert_error( + self.put(self.payload(fields=rows)), "validation_error", 400 + ) + + def test_semantic_purposes_case_options_and_new_radio_rules(self): + case = self.payload()["fields"][0] + for rows in ( + [], + [{**case, "type": "text", "options": []}], + [{**case, "required": False}], + [{**case, "options": []}], + [{**case, "options": [" A ", "a"]}], + [{**case, "options": [" "]}], + [case, new_field("url", "presentation")], + [case, new_field("file", "repository")], + [case, new_field("select", "case", options=["B"])], + [case, new_field("radio", options=["A"])], + ): + with self.subTest(rows=rows): + self.assert_error( + self.put(self.payload(fields=rows)), "validation_error", 400 + ) + + def test_used_field_cannot_be_removed_retyped_or_repurposed(self): + response = self.put(self.payload(fields=[*self.payload()["fields"], new_field()])) + target = response.data["form"]["fields"][1] + self.answer(target["id"], "Текст") + for fields in ( + [self.payload()["fields"][0]], + [self.payload()["fields"][0], {**target, "type": "text"}], + [self.payload()["fields"][0], {**target, "purpose": "run_instructions"}], + ): + self.assert_error(self.put(self.payload(fields=fields)), "field_in_use") + field = self.program.fields.get(pk=target["id"]) + field.field_type = "number" + with self.assertRaises(ValidationError): + field.save() + self.assertEqual(self.program.fields.get(pk=field.pk).field_type, "textarea") + + def test_used_choice_options_preserved_exactly_while_unused_options_can_change(self): + case = self.payload()["fields"][0] + self.answer(case["id"], "Задача 1") + self.assert_error( + self.put(self.payload(fields=[{**case, "options": ["Новое имя"]}])), + "field_in_use", + ) + response = self.put( + self.payload(fields=[{**case, "options": ["Задача 1", "Новый трек"]}]) + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(ProgramSolutionAnswer.objects.get().value, "Задача 1") + + def test_narrowing_constraints_cannot_invalidate_existing_answers(self): + result = self.put(self.payload(fields=[*self.payload()["fields"], new_field()])) + target = result.data["form"]["fields"][1] + self.answer(target["id"], "Длинный ответ") + invalid = {**target, "validation": {**VALIDATION_DEFAULTS, "max_length": 2}} + self.assert_error( + self.put(self.payload(fields=[self.payload()["fields"][0], invalid])), + "field_in_use", + ) + valid = {**target, "validation": {**VALIDATION_DEFAULTS, "max_length": 100}} + self.assertEqual( + self.put( + self.payload(fields=[self.payload()["fields"][0], valid]) + ).status_code, + 200, + ) + + def test_template_change_is_blocked_after_answers_in_api_and_admin(self): + case = self.payload()["fields"][0] + self.answer(case["id"], "Задача 1") + self.assert_error( + self.put(self.payload(template="case_championship")), "template_locked" + ) + self.program.solution_template = "case_championship" + with self.assertRaises(ValidationError): + self.program.full_clean() + with self.assertRaises(ValidationError): + self.program.save(update_fields=["solution_template"]) + + def test_required_changes_count_only_affected_drafts_and_keep_zero_and_false(self): + case = self.payload()["fields"][0] + rows = [ + case, + new_field("number", position=1, required=True), + new_field("checkbox", position=2, required=True), + ] + result = self.put(self.payload(fields=rows)) + fields = result.data["form"]["fields"] + self.answer(case["id"], "Задача 1") + self.answer(fields[1]["id"], 0) + self.answer(fields[2]["id"], False) + self.assertEqual( + call(self.manager, "get", self.root).data["affected_draft_count"], 0 + ) + rows = [*fields, new_field(position=10, required=True)] + changed = self.put(self.payload(fields=rows)) + self.assertEqual(changed.data["affected_draft_count"], 1) + self.assertEqual(ProgramSolutionAnswer.objects.count(), 3) + + def test_submitted_snapshot_remains_stable_after_label_order_and_required_changes( + self, + ): + case = self.payload()["fields"][0] + self.answer(case["id"], "Задача 1") + before = solution_data(self.solution, self.captain) + snapshot = dict( + name=before["name"], + captain_id=before["captain_id"], + form=before["form"], + answers=before["answers"], + members=before["team"]["members"], + ) + snapshot["form"]["is_snapshot"] = True + self.solution.status = "submitted" + self.solution.submitted_at = timezone.now() + self.solution.submission_snapshot = snapshot + self.solution.save() + changed = self.put( + self.payload( + fields=[{**case, "label": "Новая задача"}, new_field(required=True)] + ) + ) + self.assertEqual(changed.status_code, 200, changed.data) + self.assertEqual(changed.data["affected_draft_count"], 0) + read = solution_data(self.solution, self.captain) + self.assertEqual(read["form"], snapshot["form"]) + self.assertEqual(read["answers"], snapshot["answers"]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + + def test_legacy_form_endpoint_cannot_bypass_solution_definition_rules(self): + from partner_programs.services.management_form import save_form + from rest_framework.exceptions import ValidationError as APIValidationError + + with self.assertRaises(APIValidationError): + save_form(self.program.pk, self.manager, {}) + + +class SolutionFormConcurrencyTests(FormFixture, TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") + def test_two_writes_with_same_revision_serialize(self): + self.seed() + payload = self.payload() + barrier = Barrier(2) + + def write(label): + close_old_connections() + data = copy.deepcopy(payload) + data["fields"][0]["label"] = label + try: + barrier.wait(timeout=10) + solution_forms.save_form( + program_id=self.program.pk, actor=self.manager, **data + ) + return "saved" + except SolutionError as error: + return error.payload["code"] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(write, ["Первый", "Второй"])) + self.assertCountEqual(results, ["saved", "form_revision_conflict"]) diff --git a/partner_programs/tests/test_solution_foundation.py b/partner_programs/tests/test_solution_foundation.py index 163488aa..2303087c 100644 --- a/partner_programs/tests/test_solution_foundation.py +++ b/partner_programs/tests/test_solution_foundation.py @@ -30,6 +30,8 @@ def solution_program(**kwargs): + # Готовая к участию fixture; production default отдельно проверяется как False. + kwargs.setdefault("solution_creation_enabled", True) program = create_partner_program( solution_storage_mode="solution", solution_template="hackathon", **kwargs ) @@ -69,7 +71,10 @@ def test_create_and_read_match_frozen_contract_without_project(self): self.assertEqual(obj.members.get().user_id, self.user.pk) self.assertEqual(obj.captain_id, self.user.pk) self.assertEqual(response.data["team"]["accepted_count"], 1) - self.assertFalse(any(response.data["permissions"].values())) + self.assertTrue(response.data["permissions"]["can_manage_team"]) + self.assertTrue(response.data["permissions"]["can_edit"]) + self.assertTrue(response.data["permissions"]["can_invite"]) + self.assertTrue(response.data["permissions"]["can_submit"]) self.assertEqual(response.data["answers"], []) self.assertEqual(response.data["submission"]["status"], "draft") self.assertEqual(self.client.get(f"{self.url}{obj.pk}/").data, response.data) @@ -353,14 +358,14 @@ def test_foundation_constraints_and_no_team_mutation_endpoints(self): self.client.patch( f"{self.url}{solution_id}/", {"name": "X"}, format="json" ).status_code, - 405, + 400, ) - for suffix in ["members/", "invitations/", "submit/"]: + for suffix, expected_status in [("members/", 404), ("submit/", 400)]: self.assertEqual( self.client.post( f"{self.url}{solution_id}/{suffix}", {}, format="json" ).status_code, - 404, + expected_status, ) def test_anonymous_api_uses_contract_error_envelope(self): diff --git a/partner_programs/tests/test_solution_invitation_roles.py b/partner_programs/tests/test_solution_invitation_roles.py new file mode 100644 index 00000000..6bf2f081 --- /dev/null +++ b/partner_programs/tests/test_solution_invitation_roles.py @@ -0,0 +1,215 @@ +import json +from pathlib import Path + +from django.test import TestCase +from jsonschema import Draft202012Validator + +from partner_programs import solution_invitations +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import SolutionInvitation +from partner_programs.tests.test_solution_invitations import InvitationFixture, call + + +class SolutionInvitationRoleTests(InvitationFixture, TestCase): + def setUp(self): + self.seed() + + def state(self): + self.solution.refresh_from_db() + return { + "version": self.solution.version, + "updated_at": self.solution.updated_at, + "members": list(self.solution.members.order_by("pk").values()), + "invitations": list(self.solution.invitations.order_by("pk").values()), + } + + def create_body(self, **changes): + return { + "expected_solution_version": self.solution.version, + "recipient_user_id": self.recipient.pk, + **changes, + } + + def test_create_requires_nonblank_string_role_and_failed_commands_change_nothing( + self, + ): + before = self.state() + for fields in ( + {}, + {"role": None}, + {"role": ""}, + {"role": " \t\n"}, + {"role": "\u00a0\u2003"}, + {"role": True}, + {"role": 1}, + {"role": []}, + {"role": {}}, + {"role": "Р" * 129}, + ): + with self.subTest(fields=fields): + self.assert_error( + call(self.captain, "post", self.root, self.create_body(**fields)), + 400, + "validation_error", + ) + self.assertEqual(self.state(), before) + + def test_create_accepts_one_and_128_characters_without_changing_the_role(self): + for role in ("Р", "Р" * 128, " Своя роль "): + with self.subTest(length=len(role)): + recipient = self.registered() + self.solution.refresh_from_db() + self.program.legacy_team_max_size = 5 + self.program.save() + result = call( + self.captain, + "post", + self.root, + {**self.create_body(role=role), "recipient_user_id": recipient.pk}, + ) + self.assertEqual(result.status_code, 201, result.data) + self.assertEqual(result.data["invitation"]["role"], role) + self.assertEqual(result.data["invitation"]["status"], "pending") + self.assertEqual(self.solution.members.count(), 1) + + def test_patch_cannot_clear_role_or_change_versions_on_invalid_input(self): + invitation = self.invite() + before = self.state() + url = f"/solution-invitations/{invitation['id']}/" + for role in (None, "", " \n\t", "\u00a0", True, 123, [], {}, "Р" * 129): + with self.subTest(role=role): + self.assert_error( + call( + self.captain, + "patch", + url, + { + "expected_version": invitation["version"], + "expected_solution_version": self.solution.version, + "role": role, + }, + ), + 400, + "validation_error", + ) + self.assertEqual(self.state(), before) + + def test_partial_patch_keeps_historical_null_role(self): + invitation = SolutionInvitation.objects.create( + solution=self.solution, recipient=self.recipient, role=None + ) + result = call( + self.captain, + "patch", + f"/solution-invitations/{invitation.pk}/", + { + "expected_version": invitation.version, + "expected_solution_version": self.solution.version, + "motivational_letter": "Новое сообщение", + }, + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertIsNone(result.data["invitation"]["role"]) + self.assertEqual( + result.data["invitation"]["motivational_letter"], "Новое сообщение" + ) + + def legacy_invitation(self): + return SolutionInvitation.objects.create( + solution=self.solution, recipient=self.recipient, role=None + ) + + def test_historical_null_role_still_reads_and_accepts_without_fabricating_a_role( + self, + ): + invitation = self.legacy_invitation() + url = f"/solution-invitations/{invitation.pk}/" + detail = call(self.recipient, "get", url) + self.assertEqual(detail.status_code, 200, detail.data) + self.assertIsNone(detail.data["role"]) + accepted = call(self.recipient, "post", url + "accept/", {"expected_version": 1}) + self.assertEqual(accepted.status_code, 200, accepted.data) + self.assertIsNone(accepted.data["invitation"]["role"]) + self.assertIsNone(self.solution.members.get(user=self.recipient).role) + + def test_historical_null_role_can_be_declined(self): + invitation = self.legacy_invitation() + result = call( + self.recipient, + "post", + f"/solution-invitations/{invitation.pk}/decline/", + {"expected_version": 1}, + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(result.data["invitation"]["status"], "declined") + self.assertIsNone(result.data["invitation"]["role"]) + + def test_historical_null_role_can_be_revoked(self): + invitation = self.legacy_invitation() + result = call( + self.captain, + "delete", + f"/solution-invitations/{invitation.pk}/?expected_version=1&expected_solution_version=1", + ) + self.assertEqual(result.status_code, 204) + self.assertFalse(SolutionInvitation.objects.filter(pk=invitation.pk).exists()) + self.assertEqual(self.solution.members.count(), 1) + + def test_direct_service_creation_cannot_bypass_required_role(self): + before = self.state() + for fields in ( + {}, + {"role": None}, + {"role": " "}, + {"role": 123}, + {"role": "Р" * 129}, + ): + with self.subTest(fields=fields): + with self.assertRaises(SolutionError) as error: + solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **self.create_body(**fields), + ) + self.assertEqual(error.exception.status_code, 400) + self.assertEqual(str(error.exception.get_codes()), "validation_error") + self.assertEqual(self.state(), before) + + def test_direct_service_patch_cannot_bypass_role_validation(self): + invitation = self.invite() + before = self.state() + with self.assertRaises(SolutionError) as error: + solution_invitations.update_invitation( + invitation_id=invitation["id"], + actor=self.captain, + expected_version=1, + expected_solution_version=self.solution.version, + role=None, + ) + self.assertEqual(error.exception.status_code, 400) + self.assertEqual(self.state(), before) + + def test_c00_matches_invalid_and_partial_role_requests(self): + spec = json.loads( + ( + Path(__file__).parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + schema = spec["components"]["schemas"] + create = Draft202012Validator(schema["CreateInvitation"]) + for fields in ( + {}, + {"role": None}, + {"role": ""}, + {"role": " \t"}, + {"role": "Р" * 129}, + ): + self.assertFalse(create.is_valid(self.create_body(**fields))) + for role in ("Р", "Р" * 128): + self.assertTrue(create.is_valid(self.create_body(role=role))) + patch = Draft202012Validator(schema["UpdateInvitation"]) + version = {"expected_version": 1, "expected_solution_version": 1} + self.assertTrue(patch.is_valid({**version, "motivational_letter": "Текст"})) + self.assertFalse(patch.is_valid({**version, "role": None})) diff --git a/partner_programs/tests/test_solution_invitations.py b/partner_programs/tests/test_solution_invitations.py new file mode 100644 index 00000000..00e466bf --- /dev/null +++ b/partner_programs/tests/test_solution_invitations.py @@ -0,0 +1,492 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +from django.db import close_old_connections +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs import solution_invitations, solution_team +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import create_user, create_program_member +from partner_programs.tests.test_solution_foundation import solution_program +from partner_programs.tests.test_solution_team import TeamFixture + + +def call(actor, method, url, data=None): + client = APIClient() + client.force_authenticate(actor) + return getattr(client, method)(url, {} if data is None else data, format="json") + + +class InvitationFixture(TeamFixture): + def seed(self): + self.program = solution_program(is_competitive=True, legacy_team_max_size=3) + self.solution = self.team() + self.captain = self.solution.captain + self.recipient = self.registered() + self.root = ( + f"/programs/{self.program.pk}/solutions/{self.solution.pk}/invitations/" + ) + + def create(self, recipient=None, **fields): + self.solution.refresh_from_db() + return call( + self.captain, + "post", + self.root, + dict( + expected_solution_version=self.solution.version, + recipient_user_id=(recipient or self.recipient).pk, + role="Аналитик", + specialization="Данные", + motivational_letter="Присоединяйтесь", + **fields, + ), + ) + + def invite(self, recipient=None): + result = self.create(recipient) + self.assertEqual(result.status_code, 201, result.data) + return result.data["invitation"] + + def action(self, invite, actor=None, decision="accept", version=None): + return call( + actor or self.recipient, + "post", + f"/solution-invitations/{invite['id']}/{decision}/", + {"expected_version": invite["version"] if version is None else version}, + ) + + def validate_schema(self, response, name): + from jsonschema import Draft202012Validator + + contract = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf8") + ) + Draft202012Validator( + {"$ref": "#/components/schemas/" + name, "components": contract["components"]} + ).validate(response.data) + + +class SolutionInvitationTests(InvitationFixture, TestCase): + def setUp(self): + self.seed() + + def test_create_accept_and_read_use_c00_without_project_or_independent_team(self): + result = self.create() + self.validate_schema(result, "InvitationCreated") + invite = result.data["invitation"] + self.assertEqual(result.data["solution_version"], 2) + self.assertTrue(invite["permissions"]["can_edit"]) + self.assertEqual(self.solution.members.count(), 1) + self.assert_error( + call( + self.recipient, + "get", + f"/programs/{self.program.pk}/solutions/{self.solution.pk}/", + ), + 404, + "not_found", + ) + detail = call(self.recipient, "get", f"/solution-invitations/{invite['id']}/") + self.validate_schema(detail, "Invitation") + self.assertTrue(detail.data["permissions"]["can_accept"]) + self.assertNotIn("answers", detail.data) + self.assertNotIn("team", detail.data["solution"]) + accepted = self.action(invite) + self.assertEqual(accepted.status_code, 200, accepted.data) + self.validate_schema(accepted, "InvitationActionResult") + self.assertEqual(accepted.data["solution_version"], 3) + self.assertEqual(accepted.data["application"]["solution_id"], self.solution.pk) + self.assertEqual(accepted.data["invitation"]["version"], 2) + member = self.solution.members.get(user=self.recipient) + self.assertEqual((member.role, member.specialization), ("Аналитик", "Данные")) + self.assertEqual(self.solution.members.count(), 2) + self.assertFalse(any(accepted.data["invitation"]["permissions"].values())) + current = call( + self.recipient, "get", f"/programs/{self.program.pk}/solution-context/" + ) + self.assertEqual( + current.data["current_application"]["solution_id"], self.solution.pk + ) + + def test_inbox_and_solution_list_are_scoped_and_paginated(self): + first = self.invite() + self.invite(self.registered()) + inbox = call(self.recipient, "get", "/solution-invitations/?limit=1") + self.validate_schema(inbox, "InvitationPage") + self.assertEqual(inbox.data["count"], 1) + self.assertEqual(inbox.data["results"][0]["id"], first["id"]) + sent = call(self.captain, "get", self.root + "?limit=1") + self.validate_schema(sent, "InvitationPage") + self.assertEqual(sent.data["count"], 2) + self.assertIsNotNone(sent.data["next"]) + self.assertEqual(sent.data["results"][0]["id"], first["id"]) + self.assertEqual( + call(self.captain, "get", "/solution-invitations/").data["count"], 0 + ) + self.assert_error(call(self.recipient, "get", self.root), 404, "not_found") + self.action(first) + self.assert_error( + call(self.recipient, "get", self.root), 403, "not_solution_captain" + ) + + def test_recipient_cannot_mutate_as_captain_and_other_roles_cannot_read_invite(self): + invite = self.invite() + url = f"/solution-invitations/{invite['id']}/" + for other in [self.registered(), create_user(is_staff=True)]: + self.program.managers.add(other) + self.assert_error(call(other, "get", url), 404, "not_found") + self.assert_error( + call( + other, + "patch", + url, + {"expected_version": 1, "expected_solution_version": 2, "role": "X"}, + ), + 404, + "not_found", + ) + self.assert_error( + call( + self.recipient, + "patch", + url, + {"expected_version": 1, "expected_solution_version": 2, "role": "X"}, + ), + 403, + "not_solution_captain", + ) + self.assert_error( + self.action(invite, actor=self.captain), 403, "not_invite_recipient" + ) + + def test_patch_changes_only_fields_and_both_versions(self): + invite = self.invite() + result = call( + self.captain, + "patch", + f"/solution-invitations/{invite['id']}/", + { + "expected_version": 1, + "expected_solution_version": 2, + "role": "Новая роль", + "motivational_letter": "", + }, + ) + self.assertEqual(result.status_code, 200, result.data) + self.validate_schema(result, "InvitationCreated") + self.assertEqual(result.data["invitation"]["role"], "Новая роль") + self.assertEqual(result.data["invitation"]["specialization"], "Данные") + self.assertEqual(result.data["solution_version"], 3) + self.assertEqual(result.data["invitation"]["version"], 2) + + def test_revoke_requires_both_versions_and_deletes_only_pending(self): + invite = self.invite() + url = f"/solution-invitations/{invite['id']}/" + self.assert_error( + call( + self.captain, + "delete", + url + "?expected_version=1&expected_solution_version=1", + ), + 409, + "version_conflict", + ) + result = call( + self.captain, + "delete", + url + "?expected_version=1&expected_solution_version=2", + ) + self.assertEqual(result.status_code, 204) + self.assertFalse(SolutionInvitation.objects.exists()) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 3) + self.assertEqual(self.solution.members.count(), 1) + + def test_stale_invitation_or_solution_version_has_no_partial_write(self): + invite = self.invite() + self.assert_error(self.action(invite, version=2), 409, "version_conflict") + self.assert_error( + call( + self.captain, + "patch", + f"/solution-invitations/{invite['id']}/", + {"expected_version": 2, "expected_solution_version": 2, "role": "X"}, + ), + 409, + "version_conflict", + ) + self.assert_error( + call( + self.captain, + "patch", + f"/solution-invitations/{invite['id']}/", + {"expected_version": 1, "expected_solution_version": 1, "role": "X"}, + ), + 409, + "version_conflict", + ) + obj = SolutionInvitation.objects.get() + self.assertEqual((obj.version, obj.role, obj.status), (1, "Аналитик", "pending")) + self.assertEqual(self.solution.members.count(), 1) + + def test_duplicate_and_accepted_member_do_not_consume_new_reserve(self): + invite = self.invite() + self.assert_error(self.create(), 409, "duplicate_pending_invite") + self.assert_error(self.create(self.captain), 409, "already_solution_member") + self.action(invite) + self.assert_error(self.create(), 409, "already_solution_member") + self.assertEqual(self.solution.invitations.count(), 1) + + def test_maximum_includes_pending_but_accept_uses_own_reserved_place(self): + self.program.legacy_team_max_size = 2 + self.program.save() + invite = self.invite() + self.assert_error(self.create(self.registered()), 409, "team_max_size") + self.assertEqual(self.action(invite).status_code, 200) + self.assertEqual(self.solution.members.count(), 2) + + def test_accept_rechecks_membership_and_exclusivity(self): + invite = self.invite() + PartnerProgramUserProfile.objects.filter( + user=self.recipient, partner_program=self.program + ).delete() + self.assert_error(self.action(invite), 409, "not_program_member") + create_program_member(self.program, user=self.recipient) + other = self.team() + ProgramSolutionMember.objects.create(solution=other, user=self.recipient) + self.assert_error(self.action(invite), 409, "already_in_program_team") + self.assertEqual(SolutionInvitation.objects.get().status, "pending") + + def test_repeated_accept_decline_patch_and_revoke_do_not_change_history(self): + invite = self.invite() + accepted = self.action(invite) + for response in [ + self.action(invite), + self.action(invite, decision="decline"), + call( + self.captain, + "delete", + f"/solution-invitations/{invite['id']}/?expected_version=2&expected_solution_version=3", + ), + call( + self.captain, + "patch", + f"/solution-invitations/{invite['id']}/", + {"expected_version": 2, "expected_solution_version": 3, "role": "X"}, + ), + ]: + self.assert_error(response, 409, "invite_already_processed") + self.assertEqual(self.solution.members.filter(user=self.recipient).count(), 1) + self.assertEqual( + SolutionInvitation.objects.get().version, + accepted.data["invitation"]["version"], + ) + + def test_decline_and_revoke_after_submit_preserve_snapshot_and_change_service_versions( + self, + ): + first = self.invite() + second = self.invite(self.registered()) + snapshot = { + "name": "Сданное решение", + "captain_id": self.captain.pk, + "members": [], + "form": {}, + "answers": [], + } + ProgramSolution.objects.filter(pk=self.solution.pk).update( + status="submitted", submitted_at=timezone.now(), submission_snapshot=snapshot + ) + self.assert_error(self.action(first), 409, "team_frozen") + self.assert_error( + call( + self.captain, + "patch", + f"/solution-invitations/{first['id']}/", + {"expected_version": 1, "expected_solution_version": 3, "role": "X"}, + ), + 409, + "team_frozen", + ) + self.assert_error(self.create(self.registered()), 409, "team_frozen") + declined = self.action(first, decision="decline") + self.assertEqual(declined.status_code, 200, declined.data) + self.validate_schema(declined, "InvitationActionResult") + self.assertIsNone(declined.data["solution_version"]) + self.assertIsNone(declined.data["application"]) + revoked = call( + self.captain, + "delete", + f"/solution-invitations/{second['id']}/?expected_version=1&expected_solution_version=4", + ) + self.assertEqual(revoked.status_code, 204) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 5) + self.assertEqual(self.solution.submission_snapshot, snapshot) + self.assertEqual(self.solution.status, "submitted") + + def test_mode_rollback_keeps_read_but_blocks_mutations(self): + invite = self.invite() + self.program.solution_storage_mode = "legacy_project" + self.program.save() + detail = call(self.recipient, "get", f"/solution-invitations/{invite['id']}/") + self.assertEqual(detail.status_code, 200) + self.assertFalse(any(detail.data["permissions"].values())) + self.assert_error(self.action(invite), 409, "solution_mode_unavailable") + self.assert_error( + self.action(invite, decision="decline"), 409, "solution_mode_unavailable" + ) + + def test_captain_transfer_updates_invitation_ownership_without_exposing_it_to_old_captain( + self, + ): + invite = self.invite() + new = self.member() + solution_team.transfer_captain( + program_id=self.program.pk, + solution_id=self.solution.pk, + user_id=new.pk, + actor=self.captain, + expected_version=2, + ) + url = f"/solution-invitations/{invite['id']}/" + self.assert_error(call(self.captain, "get", url), 404, "not_found") + self.assertEqual(call(new, "get", url).data["captain"]["id"], new.pk) + self.assertEqual(self.action(invite).status_code, 200) + + def test_strict_fields_types_lengths_and_identifiers(self): + for fields in [ + {"project_id": 1}, + {"solution_id": 1}, + {"role": True}, + {"role": "x" * 129}, + {"specialization": "x" * 101}, + {"motivational_letter": "x" * 4097}, + {"recipient_user_id": True}, + ]: + data = { + "expected_solution_version": 1, + "recipient_user_id": self.recipient.pk, + **fields, + } + self.assert_error( + call(self.captain, "post", self.root, data), 400, "validation_error" + ) + invite = self.invite() + self.assert_error( + call( + self.captain, + "patch", + f"/solution-invitations/{invite['id']}/", + {"expected_version": 1, "expected_solution_version": 2}, + ), + 400, + "validation_error", + ) + self.assert_error( + call(self.recipient, "get", "/solution-invitations/9007199254740992/"), + 404, + "not_found", + ) + self.assert_error( + call(self.captain, "get", self.root + "?status=revoked"), + 400, + "validation_error", + ) + + def test_pending_in_other_team_is_not_accepted_membership_and_finished_exceptions_hold( + self, + ): + invite = self.invite() + other = self.team() + other_invitation = solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=other.pk, + actor=other.captain, + expected_solution_version=1, + recipient_user_id=self.recipient.pk, + role="Аналитик", + ) + self.assertEqual(self.action(invite).status_code, 200) + self.program.datetime_finished = timezone.now() - timezone.timedelta(days=1) + self.program.save() + self.assertEqual( + self.action({"id": other_invitation.pk, "version": 1}).status_code, 200 + ) + + +@skipUnlessDBFeature("has_select_for_update") +class SolutionInvitationConcurrencyTests(InvitationFixture, TransactionTestCase): + def setUp(self): + self.seed() + + def parallel(self, functions): + barrier = Barrier(len(functions)) + + def run(function): + close_old_connections() + try: + barrier.wait(timeout=15) + function() + return "success" + except SolutionError as error: + return str(error.get_codes()) + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=len(functions)) as pool: + return list(pool.map(run, functions)) + + def test_two_creates_of_same_version_have_one_pending_and_one_version_increment(self): + def create(): + solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + expected_solution_version=1, + recipient_user_id=self.recipient.pk, + role="Аналитик", + ) + + results = self.parallel([create, create]) + self.assertCountEqual(results, ["success", "version_conflict"]) + self.assertEqual(SolutionInvitation.objects.count(), 1) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 2) + + def test_acceptance_of_two_solution_invitations_only_joins_one_team(self): + first = self.invite() + other = self.team() + second = solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=other.pk, + actor=other.captain, + expected_solution_version=1, + recipient_user_id=self.recipient.pk, + role="Аналитик", + ) + + def accept(pk): + solution_invitations.decide_invitation( + invitation_id=pk, actor=self.recipient, expected_version=1, accept=True + ) + + results = self.parallel([lambda: accept(first["id"]), lambda: accept(second.pk)]) + self.assertCountEqual(results, ["success", "already_in_program_team"]) + self.assertEqual( + ProgramSolutionMember.objects.filter(user=self.recipient).count(), 1 + ) diff --git a/partner_programs/tests/test_solution_manager_analytics.py b/partner_programs/tests/test_solution_manager_analytics.py new file mode 100644 index 00000000..f71b0971 --- /dev/null +++ b/partner_programs/tests/test_solution_manager_analytics.py @@ -0,0 +1,461 @@ +import copy +from io import BytesIO +from unittest.mock import patch + +from django.db import connection +from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs import solution_forms +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.solution_analytics import SolutionAnalyticsContext +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.solution_schema import form_data +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) +from partner_programs.tests.test_solution_forms import call, new_field +from project_rates.models import Criteria +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionManagerAnalyticsTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.root = f"/programs/{self.program.pk}/management/solution-analytics/" + + def get(self, suffix="", schema="ManagerAnalytics", query=""): + response = call(self.manager, "get", self.root + suffix + query) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assert_schema(schema, response.data) + return response.data + + def book(self, kind="program"): + response = call(self.manager, "get", self.root + f"export/?kind={kind}") + self.assertEqual(response.status_code, 200, getattr(response, "data", None)) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertIn("spreadsheetml.sheet", response["Content-Type"]) + return load_workbook(BytesIO(response.content), data_only=False) + + def test_all_operations_require_program_manager_and_explicit_solution_mode(self): + suffixes = [ + "", + "assignments/", + f"assignments/{self.assignment.pk}/", + "solutions/", + "export/", + ] + for suffix in suffixes: + for actor in [self.captain, self.expert, create_user()]: + response = call(actor, "get", self.root + suffix) + self.assertEqual(response.status_code, 403, (suffix, response.data)) + self.assert_schema("Error", response.data) + response = APIClient().get(self.root + suffix) + self.assertEqual(response.status_code, 401) + self.assert_schema("Error", response.data) + other = create_partner_program() + other.managers.add(self.manager) + for suffix in suffixes: + response = call( + self.manager, + "get", + f"/programs/{other.pk}/management/solution-analytics/" + suffix, + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "solution_mode_unavailable") + staff = create_user(is_staff=True) + self.assertEqual(call(staff, "get", self.root).status_code, 200) + + def test_summary_real_solutions_registered_accepted_team_and_case_parity(self): + member, pending = create_user(city="Москва"), create_user() + for user in [member, pending]: + create_program_member(self.program, user=user) + draft = self.another_solution(submitted=False) + ProgramSolutionMember.objects.create(solution=draft, user=member) + SolutionInvitation.objects.create(solution=draft, recipient=pending, role="Роль") + project = create_project(leader=pending, region="Только регион проекта") + PartnerProgramProject.objects.bulk_create( + [PartnerProgramProject(partner_program=self.program, project=project)] + ) + data = self.get() + self.assertEqual(data["summary"]["solutions"]["total"], 2) + self.assertEqual(data["summary"]["participants"]["total"], 4) + self.assertEqual(data["participant_funnel"]["with_team"], 3) + self.assertEqual(data["participant_funnel"]["captains"], 2) + self.assertEqual(data["attention"]["participants_without_team"], 1) + self.assertEqual( + data["solution_funnel"], + dict( + created=2, + submission_applicable=True, + not_submitted=1, + submitted=1, + evaluated=0, + ), + ) + self.assertEqual( + data["cases"], + call( + self.manager, + "get", + f"/programs/{self.program.pk}/management/solution-cases/", + ).data, + ) + self.assertNotIn("regions", data["summary"]) + self.assertNotIn("Только регион проекта", str(data)) + + def test_distributed_complete_values_zero_false_and_real_quota(self): + self.program.required_evaluators = 2 + self.program.save(update_fields=["required_evaluators"]) + SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.second.expert, + ) + self.assertEqual(self.rate().status_code, 200) + data = self.get() + self.assertEqual( + data["evaluation_status"]["assignments"], + dict(total=2, pending=1, evaluated=1), + ) + self.assertEqual(data["evaluation_status"]["solutions"]["partially_evaluated"], 1) + self.assertEqual(data["attention"]["solutions_awaiting_evaluation"], 1) + self.assertEqual(self.rate(actor=self.second).status_code, 200) + data = self.get() + self.assertEqual(data["solution_funnel"]["evaluated"], 1) + self.assertEqual(data["attention"]["solutions_awaiting_evaluation"], 0) + detail = self.get(f"assignments/{self.assignment.pk}/", "ManagerAssignmentDetail") + values = {row["type"]: row for row in detail["scores"]} + self.assertEqual(values["int"]["value"], "0") + self.assertEqual(values["bool"]["value"], "False") + self.assertTrue(values["str"]["is_scored"]) + self.assertEqual(detail["status"], "completed") + + def test_unassigned_score_and_invalid_value_do_not_complete_assignment( + self, + ): + SolutionScore.objects.create( + solution=self.solution, user=self.second, criteria=self.criteria[0], value="0" + ) + self.assertEqual(self.get()["solution_funnel"]["evaluated"], 0) + self.assertEqual(self.rate().status_code, 200) + SolutionScore.objects.filter( + solution=self.solution, user=self.expert, criteria=self.criteria[0] + ).update(value="invalid") + self.assertEqual(self.get()["evaluation_status"]["assignments"]["evaluated"], 0) + + def test_no_criteria_does_not_complete_assignment(self): + Criteria.objects.filter(partner_program=self.program).delete() + self.assertEqual( + self.get()["evaluation_status"]["assignments"], + dict(total=1, pending=1, evaluated=0), + ) + + def test_open_first_score_and_noncompetitive_inapplicable_counts(self): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + self.assertIsNone(self.get()["evaluation_status"]["assignments"]) + SolutionScore.objects.create( + solution=self.solution, user=self.second, criteria=self.criteria[0], value="0" + ) + self.assertEqual(self.get()["solution_funnel"]["evaluated"], 1) + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + draft = self.another_solution(submitted=False) + data = self.get() + self.assertEqual(data["summary"]["solutions"]["total"], 2) + for key in ("submitted", "not_submitted", "evaluated"): + self.assertIsNone(data["solution_funnel"][key]) + self.assertIsNone(data["evaluation_status"]["solutions"]) + self.assertIsNone(data["participant_funnel"]["submitted_captains"]) + self.assertTrue( + all(row["submitted_solutions"] is None for row in data["activity"]) + ) + page = self.get("solutions/", "ManagerSolutions", "?scope=not_submitted") + self.assertEqual(page["count"], 0) + draft.refresh_from_db() + self.assertEqual(draft.status, "draft") + + def test_field_coverage_uses_each_applied_form_and_preserves_zero_false(self): + self.another_solution(submitted=False) + fields = form_data(self.program)["fields"] + solution_forms.save_form( + program_id=self.program.pk, + actor=self.manager, + **self.payload( + fields=[ + *fields, + new_field( + required=True, label="Новое обязательное поле", position=50 + ), + ] + ), + ) + data = self.get() + coverage = {row["label"]: row for row in data["field_coverage"]} + new = coverage["Новое обязательное поле"] + self.assertEqual( + [ + new[k] + for k in ("applicable", "required_applicable", "required_unanswered") + ], + [1, 1, 1], + ) + by_type = {row["type"]: row for row in data["field_coverage"]} + self.assertEqual(by_type["number"]["answered"], 1) + self.assertEqual(by_type["checkbox"]["answered"], 1) + self.assertEqual(by_type["file"]["required_unanswered"], 0) + self.assertEqual(by_type["file"]["required_applicable"], 0) + + def test_waiting_delayed_experts_existing_thresholds_and_not_ready(self): + now = timezone.now() + ProgramSolution.objects.filter(pk=self.solution.pk).update( + submitted_at=now - timezone.timedelta(hours=25) + ) + SolutionExpertAssignment.objects.filter(pk=self.assignment.pk).update( + datetime_created=now - timezone.timedelta(hours=26) + ) + self.program.datetime_evaluation_ends = now + timezone.timedelta(days=4) + self.program.save(update_fields=["datetime_evaluation_ends"]) + with patch( + "partner_programs.services.solution_analytics.timezone.now", return_value=now + ): + assignment = self.get("assignments/", "ManagerAssignments")["results"][0] + self.assertEqual(assignment["waiting_seconds"], 25 * 3600) + delayed = self.get()["attention"]["delayed_experts"]["items"][0] + self.assertEqual( + (delayed["overdue_24h"], delayed["overdue_48h"], delayed["severity"]), + (1, 0, "warning"), + ) + self.program.datetime_evaluation_ends = now - timezone.timedelta(hours=1) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual( + self.get()["attention"]["delayed_experts"]["items"][0]["severity"], "critical" + ) + self.program.datetime_evaluation_ends = now + timezone.timedelta(days=6) + self.program.save(update_fields=["datetime_evaluation_ends"]) + self.assertEqual(self.get()["attention"]["delayed_experts"]["total"], 0) + draft = self.another_solution(submitted=False) + raw = SolutionExpertAssignment( + partner_program=self.program, solution=draft, expert=self.expert.expert + ) + SolutionExpertAssignment.objects.bulk_create([raw]) + rows = self.get("assignments/", "ManagerAssignments")["results"] + pending = next( + row for row in rows if row["application"]["solution_id"] == draft.pk + ) + self.assertEqual(pending["status"], "not_ready") + self.assertIsNone(pending["waiting_since"]) + + def test_activity_30_day_window_real_submission_and_zero_days(self): + old = self.another_solution() + ProgramSolution.objects.filter(pk=old.pk).update( + submitted_at=timezone.now() - timezone.timedelta(days=31) + ) + activity = self.get()["activity"] + self.assertEqual(len(activity), 30) + self.assertEqual(sum(row["submitted_solutions"] for row in activity), 1) + self.assertTrue(any(row["submitted_solutions"] == 0 for row in activity)) + + def test_missing_historical_timestamp_never_invents_wait_duration(self): + context = SolutionAnalyticsContext(self.program) + # New submitted rows require a timestamp in DB. Check defensive reader + # behavior in memory without bypassing or dropping that constraint. + context.by_id[self.solution.pk].submitted_at = None + assignment = context.assignment(self.assignment) + self.assertEqual(assignment["status"], "pending") + self.assertIsNone(assignment["waiting_since"]) + self.assertIsNone(assignment["waiting_seconds"]) + + def test_program_workbook_keeps_defensive_legacy_registration_reading(self): + PartnerProgramUserProfile.objects.filter(partner_program=self.program).update( + registration_schema=[], partner_program_data={"legacy": "=HYPERLINK(1)"} + ) + book = self.book() + self.assertIn("Анкеты", book.sheetnames) + self.assertEqual(book["Анкеты"]["F2"].value, "=HYPERLINK(1)") + self.assertEqual(book["Анкеты"]["F2"].data_type, "s") + + def test_solution_list_filters_snapshot_names_pagination_and_reason(self): + self.another_solution(submitted=False) + ProgramSolution.objects.filter(pk=self.solution.pk).update( + name="Измененное live имя" + ) + page = self.get("solutions/", "ManagerSolutions", "?limit=1") + self.assertEqual(page["count"], 2) + self.assertIn("offset=1", page["next"]) + self.assertEqual( + page["results"][0]["application"]["name"], + self.solution.submission_snapshot["name"], + ) + self.assertEqual(page["results"][0]["waiting_reason"], "no_completed_evaluations") + waiting = self.get("solutions/", "ManagerSolutions", "?scope=awaiting_evaluation") + self.assertEqual( + waiting["count"], self.get()["attention"]["solutions_awaiting_evaluation"] + ) + self.assertEqual( + self.get("solutions/", "ManagerSolutions", "?scope=not_submitted")["count"], 1 + ) + self.assertEqual( + self.get( + "solutions/", + "ManagerSolutions", + "?case_scope=selected&case_name=Задача+1", + )["count"], + 1, + ) + self.assertEqual( + self.get("solutions/", "ManagerSolutions", "?case_scope=without_case")[ + "count" + ], + 1, + ) + self.assertEqual( + self.get("solutions/", "ManagerSolutions", "?search=Измененное")["count"], 0 + ) + self.assertEqual( + self.get("solutions/", "ManagerSolutions", "?offset=99")["results"], [] + ) + for query in ( + "?case_scope=selected", + "?case_scope=selected&case_name=bad", + "?limit=0", + "?offset=-1", + "?role=manager", + ): + self.assertEqual( + call(self.manager, "get", self.root + "solutions/" + query).status_code, + 400, + ) + + def test_assignment_scope_foreign_ids_and_foreign_solution_never_leak(self): + for invalid_id in (0, 9007199254740992, 999999999999999999999999): + response = call(self.manager, "get", self.root + f"assignments/{invalid_id}/") + self.assertEqual(response.status_code, 404) + self.assert_schema("Error", response.data) + other = EvaluationFixture() + other.seed_evaluation() + self.assertEqual( + call( + self.manager, "get", self.root + f"assignments/{other.assignment.pk}/" + ).status_code, + 404, + ) + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=self.program, + solution=other.solution, + expert=self.expert.expert, + ) + ] + ) + rows = self.get("assignments/", "ManagerAssignments")["results"] + self.assertEqual([row["assignment_id"] for row in rows], [self.assignment.pk]) + self.assertNotEqual(rows[0]["expert"]["expert_id"], rows[0]["expert"]["user_id"]) + self.assertEqual( + self.get("assignments/", "ManagerAssignments", "?scope=completed")["results"], + [], + ) + self.assertEqual( + len( + self.get("assignments/", "ManagerAssignments", "?scope=pending")[ + "results" + ] + ), + 1, + ) + self.assertEqual(self.rate().status_code, 200) + self.assertEqual( + self.get("assignments/", "ManagerAssignments", "?scope=pending")["results"], + [], + ) + + def test_xlsx_program_subsets_snapshot_answers_scores_and_formula_safety(self): + self.assertEqual(self.rate().status_code, 200) + SolutionScore.objects.create( + solution=self.solution, user=self.second, criteria=self.criteria[0], value="1" + ) + draft = self.another_solution(submitted=False) + snapshot = copy.deepcopy(self.solution.submission_snapshot) + snapshot["name"] = "=HYPERLINK(1)" + ProgramSolution.objects.filter(pk=self.solution.pk).update( + submission_snapshot=snapshot + ) + book = self.book() + self.assertIn("Ответы решения", book.sheetnames) + self.assertIn("Анкеты", book.sheetnames) + self.assertEqual(dict(book["Сводка"].values)["Решения"], 2) + rows = list(book["Решения"].values) + self.assertEqual({row[0] for row in rows[1:]}, {self.solution.pk, draft.pk}) + for sheet in book: + self.assertNotIn("ID проекта", next(sheet.values)) + for row in sheet: + for cell in row: + self.assertNotEqual(cell.data_type, "f") + values = [row[-1] for row in list(book["Ответы решения"].values)[1:]] + self.assertTrue(any(type(value) is int and value == 0 for value in values)) + self.assertTrue(any(type(value) is bool and value is False for value in values)) + self.assertEqual(len(list(book["Оценки"].values)), 5) + submitted = self.book("submitted") + self.assertEqual( + [row[0] for row in list(submitted["Решения"].values)[1:]], [self.solution.pk] + ) + self.assertEqual(set(self.book("scores").sheetnames), {"Критерии", "Оценки"}) + self.assertNotIn("Оценки", self.book("solutions").sheetnames) + response = call(self.manager, "get", self.root + "export/?kind=invalid") + self.assertEqual(response.status_code, 400) + self.assert_schema("Error", response.data) + + def test_empty_program_noncompetitive_workbook_and_readonly_constant_queries(self): + for suffix, schema in [ + ("", "ManagerAnalytics"), + ("assignments/", "ManagerAssignments"), + ("solutions/", "ManagerSolutions"), + ]: + with CaptureQueriesContext(connection) as queries: + self.get(suffix, schema) + self.assertLessEqual(len(queries), 30) + self.assertFalse( + any( + row["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")) + for row in queries + ) + ) + with CaptureQueriesContext(connection) as before: + self.get() + for _ in range(8): + self.another_solution(submitted=False) + with CaptureQueriesContext(connection) as after: + self.get() + self.assertEqual(len(before), len(after)) + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + book = self.book() + self.assertIsNone(dict(book["Сводка"].values)["Сдано"]) + self.assertTrue( + all(row[7] == "Не требуется" for row in list(book["Решения"].values)[1:]) + ) + empty = create_partner_program( + solution_storage_mode="solution", solution_template="hackathon" + ) + empty.managers.add(self.manager) + response = call( + self.manager, "get", f"/programs/{empty.pk}/management/solution-analytics/" + ) + self.assertEqual(response.status_code, 200) + self.assert_schema("ManagerAnalytics", response.data) + self.assertEqual(response.data["summary"]["solutions"]["total"], 0) diff --git a/partner_programs/tests/test_solution_member_actions.py b/partner_programs/tests/test_solution_member_actions.py new file mode 100644 index 00000000..bb671da4 --- /dev/null +++ b/partner_programs/tests/test_solution_member_actions.py @@ -0,0 +1,584 @@ +import copy +import json +from pathlib import Path +from time import time +from unittest.mock import patch + +from django.core import signing +from django.db import connection, transaction +from django.db.models.deletion import ProtectedError +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from jsonschema import Draft202012Validator, FormatChecker + +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.services import ( + solution_case_actions, + solution_member_actions as actions, +) +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionAdministrativeChange, + SolutionInvitation, +) +from partner_programs.solution_submission import submit_solution +from partner_programs.tests.helpers import ( + create_user, + create_program_member, + create_partner_program, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_criteria, create_rate_expert +from projects.models import Project + +FAST_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +class MemberFixture(SubmissionFixture): + def setUp(self): + self.ready() + self.member = create_user() + self.registration = create_program_member(self.program, user=self.member) + self.membership = ProgramSolutionMember.objects.create( + solution=self.solution, user=self.member, role="Backend-разработчик" + ) + self.invitation = SolutionInvitation.objects.create( + solution=self.solution, + recipient=self.member, + role=self.membership.role, + status="accepted", + ) + self.member_root = f"/programs/{self.program.pk}/management/solutions/{self.solution.pk}/members/remove/" + self.history = f"/programs/{self.program.pk}/management/solutions/{self.solution.pk}/history/" + + def preview(self, actor=None, user_id=None): + return call( + actor or self.manager, + "post", + self.member_root + "preview/", + dict(user_id=self.member.pk if user_id is None else user_id), + ) + + def submit(self, **changes): + response = super().submit(**changes) + self.solution.refresh_from_db() + return response + + def removal_command(self): + response = self.preview() + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["can_apply"], response.data) + return dict(user_id=self.member.pk, preview_token=response.data["preview_token"]) + + def apply(self, command, actor=None): + return call(actor or self.manager, "post", self.member_root + "apply/", command) + + def wire(self, response, status=200, schema="SolutionMemberRemovalPreview"): + self.assertEqual(response.status_code, status, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + { + "components": spec["components"], + "$ref": "#/components/schemas/" + (schema if status == 200 else "Error"), + }, + format_checker=FormatChecker(), + ).validate(response.data) + return response.data + + def saved(self): + self.solution.refresh_from_db() + return dict( + solution=list(ProgramSolution.objects.filter(pk=self.solution.pk).values()), + members=list(self.solution.members.order_by("pk").values()), + invitations=list(self.solution.invitations.order_by("pk").values()), + answers=list(self.solution.answers.order_by("pk").values()), + assignments=list(self.solution.expert_assignments.order_by("pk").values()), + scores=list(self.solution.scores.order_by("pk").values()), + registrations=list( + PartnerProgramUserProfile.objects.filter(partner_program=self.program) + .order_by("pk") + .values() + ), + projects=list(Project.objects.order_by("pk").values()), + ) + + def evaluation(self): + self.assertEqual(self.submit().status_code, 200) + self.expert = create_rate_expert(program=self.program) + self.assignment = SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.expert.expert, + ) + self.criterion = create_rate_criteria(self.program, min_value=0, max_value=10) + self.score = SolutionScore.objects.create( + solution=self.solution, criteria=self.criterion, user=self.expert, value="0" + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionMemberActionTests(MemberFixture, TestCase): + def test_preview_is_read_only_and_binds_exact_actor_program_solution_user(self): + before = self.saved() + result = self.wire(self.preview()) + self.assertEqual(result["member"]["user"]["id"], self.member.pk) + self.assertEqual(result["member"]["role"], "Backend-разработчик") + self.assertEqual(result["remaining_member_count"], 1) + self.assertFalse(result["is_submitted"]) + proof = signing.loads(result["preview_token"], salt=actions.SALT) + self.assertEqual( + {k: proof[k] for k in ("actor", "program", "solution", "user")}, + dict( + actor=self.manager.pk, + program=self.program.pk, + solution=self.solution.pk, + user=self.member.pk, + ), + ) + self.assertEqual(self.saved(), before) + + def test_draft_removal_preserves_registration_answers_processed_invite_and_other_solution( + self, + ): + other_program = create_partner_program(name="Другая программа") + other = ProgramSolution.objects.create(program=other_program, captain=self.member) + other_member = ProgramSolutionMember.objects.create( + solution=other, user=self.member + ) + pending = SolutionInvitation.objects.create( + solution=self.solution, recipient=self.member, role="Новая роль" + ) + unrelated = SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user(), role="Дизайнер" + ) + before = self.saved() + preview = self.wire(self.preview()) + self.assertEqual(preview["revoked_invitation_count"], 1) + result = self.wire( + self.apply( + dict(user_id=self.member.pk, preview_token=preview["preview_token"]) + ), + schema="MutationReceipt", + ) + after = self.saved() + self.assertEqual(result["solution_version"], before["solution"][0]["version"] + 1) + for key in ("answers", "registrations", "projects", "scores", "assignments"): + self.assertEqual(after[key], before[key]) + self.assertFalse(self.solution.members.filter(user=self.member).exists()) + self.assertFalse(SolutionInvitation.objects.filter(pk=pending.pk).exists()) + self.assertTrue( + SolutionInvitation.objects.filter( + pk=self.invitation.pk, status="accepted" + ).exists() + ) + self.assertTrue( + SolutionInvitation.objects.filter(pk=unrelated.pk, status="pending").exists() + ) + self.assertTrue(ProgramSolutionMember.objects.filter(pk=other_member.pk).exists()) + change = self.solution.administrative_changes.get() + self.assertEqual(change.revoked_invitation_ids, [pending.pk]) + self.assertIsNone(change.before_snapshot) + self.assertEqual(change.removed_member, preview["member"]) + self.assertEqual(call(self.member, "get", self.url).status_code, 404) + self.assertEqual( + call( + self.member, "get", f"/solution-invitations/{self.invitation.pk}/" + ).status_code, + 200, + ) + + def test_submitted_removal_changes_only_snapshot_members_and_keeps_evaluation(self): + self.program.legacy_team_min_size = 2 + self.program.save() + self.evaluation() + before = self.saved() + original = copy.deepcopy(self.solution.submission_snapshot) + preview = self.wire(self.preview()) + self.assertTrue(preview["is_submitted"]) + self.assertEqual(preview["score_count"], 1) + self.wire( + self.apply( + dict(user_id=self.member.pk, preview_token=preview["preview_token"]) + ), + schema="MutationReceipt", + ) + after = self.saved() + expected = copy.deepcopy(original) + expected["members"] = [ + m for m in original["members"] if m["user"]["id"] != self.member.pk + ] + self.assertEqual(self.solution.submission_snapshot, expected) + for key in ( + "answers", + "registrations", + "projects", + "scores", + "assignments", + "invitations", + ): + self.assertEqual(after[key], before[key]) + for key, value in before["solution"][0].items(): + if key not in ("version", "updated_at", "submission_snapshot"): + self.assertEqual(after["solution"][0][key], value) + detail = self.wire( + call( + self.manager, + "get", + self.history + str(self.solution.administrative_changes.get().pk) + "/", + ), + schema="SolutionAdminChangeDetail", + ) + self.assertEqual(detail["before_snapshot"], original) + self.assertEqual(detail["after_snapshot"], expected) + self.assertEqual(detail["change"]["action"], "remove_member") + self.assertEqual(detail["change"]["removed_member"], preview["member"]) + current = call(self.captain, "get", self.url) + self.assertEqual(current.status_code, 200) + self.assertEqual(current.data["team"]["accepted_count"], 1) + self.assertTrue(current.data["team"]["is_frozen"]) + self.assertEqual(call(self.member, "get", self.url).status_code, 404) + + def test_captain_is_visible_in_preview_but_cannot_be_removed(self): + for submitted in (False, True): + with self.subTest(submitted=submitted): + if submitted: + self.assertEqual(self.submit().status_code, 200) + before = self.saved() + result = self.wire(self.preview(user_id=self.captain.pk)) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertEqual(result["errors"][0]["code"], "captain_cannot_be_removed") + token = signing.dumps( + { + **actions.proof_context( + self.program, self.solution, self.manager, self.captain.pk + ), + "state": "not-valid", + }, + salt=actions.SALT, + ) + self.wire( + self.apply(dict(user_id=self.captain.pk, preview_token=token)), 409 + ) + self.assertEqual(self.saved(), before) + + def test_deadline_creation_switch_and_unconfigured_form_do_not_block_manager_removal( + self, + ): + self.assertEqual(self.submit().status_code, 200) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False, + solution_template=None, + datetime_project_submission_ends=timezone.now() - timezone.timedelta(days=1), + ) + self.wire(self.apply(self.removal_command()), schema="MutationReceipt") + self.assertFalse(self.solution.members.filter(user=self.member).exists()) + + def test_noncompetitive_saved_materials_remain_draft_without_artificial_submission( + self, + ): + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=False) + self.wire(self.apply(self.removal_command()), schema="MutationReceipt") + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submitted_at) + self.assertIsNone(self.solution.submission_snapshot) + + def test_ordinary_delete_leave_and_model_writes_stay_frozen_after_exception(self): + extra_user = create_user() + create_program_member(self.program, user=extra_user) + extra = ProgramSolutionMember.objects.create( + solution=self.solution, user=extra_user + ) + self.assertEqual(self.submit().status_code, 200) + self.wire(self.apply(self.removal_command()), schema="MutationReceipt") + before = self.saved() + with self.assertRaises(ProtectedError), transaction.atomic(): + extra.delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + self.solution.members.filter(pk=extra.pk).delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + self.solution.delete() + result = call( + self.captain, + "delete", + self.url + + f"members/{extra.user_id}/?expected_solution_version={self.solution.version}", + ) + self.assertEqual(result.status_code, 409) + self.assertEqual(result.data["code"], "team_frozen") + self.assertEqual(self.saved(), before) + + def test_journal_failure_rolls_back_member_pending_snapshot_version_and_scoped_permission( + self, + ): + self.evaluation() + pending = SolutionInvitation.objects.bulk_create( + [ + SolutionInvitation( + solution=self.solution, recipient=self.member, role="Дизайнер" + ) + ] + )[0] + command = self.removal_command() + before = self.saved() + with patch.object( + SolutionAdministrativeChange.objects, + "create", + side_effect=RuntimeError("journal unavailable"), + ): + with self.assertRaises(RuntimeError): + actions.apply(self.program.pk, self.solution.pk, self.manager, command) + self.assertEqual(self.saved(), before) + self.assertTrue(SolutionInvitation.objects.filter(pk=pending.pk).exists()) + with self.assertRaises(ProtectedError), transaction.atomic(): + self.membership.delete() + self.assertEqual(self.solution.administrative_changes.count(), 0) + + def test_replay_bad_signature_expiry_and_foreign_action_do_not_write(self): + command = self.removal_command() + before = self.saved() + for token in ( + "not-a-token", + signing.dumps([], salt=actions.SALT), + signing.dumps({}, salt=solution_case_actions.SALT), + ): + self.wire(self.apply({**command, "preview_token": token}), 409) + with patch("django.core.signing.time.time", return_value=time() - 601): + expired = self.removal_command() + self.wire(self.apply(expired), 409) + self.assertEqual(self.saved(), before) + self.wire(self.apply(command), schema="MutationReceipt") + after = self.saved() + self.assertEqual( + self.wire(self.apply(command), 409)["code"], "solution_action_conflict" + ) + self.assertEqual(self.saved(), after) + self.assertEqual(self.solution.administrative_changes.count(), 1) + + def test_actor_target_and_solution_binding_are_rechecked(self): + command = self.removal_command() + self.wire(self.apply(command, actor=create_user(is_staff=True)), 409) + self.wire(self.apply({**command, "user_id": self.captain.pk}), 409) + other = ProgramSolution.objects.create( + program=self.program, captain=create_user() + ) + path = f"/programs/{self.program.pk}/management/solutions/{other.pk}/members/remove/apply/" + self.wire(call(self.manager, "post", path, command), 409) + self.assertEqual(self.solution.administrative_changes.count(), 0) + + def test_direct_data_and_score_changes_without_version_bump_invalidate_confirmation( + self, + ): + self.evaluation() + changes = [ + lambda: self.solution.members.filter(user=self.member).update( + role="Новая роль" + ), + lambda: self.solution.answers.filter( + field_id=self.fields["number"]["id"] + ).update(value=9), + lambda: self.solution.scores.filter(pk=self.score.pk).update(value="8"), + lambda: self.solution.invitations.filter(pk=self.invitation.pk).update( + role="Иная роль" + ), + ] + for change in changes: + with self.subTest(change=change), transaction.atomic(): + command = self.removal_command() + change() + before = self.saved() + self.wire(self.apply(command), 409) + self.assertEqual(self.saved(), before) + transaction.set_rollback(True) + + def test_permissions_scope_and_legacy_mode_are_checked_before_members(self): + for actor, status in ( + (None, 401), + (self.captain, 403), + (self.member, 403), + (create_user(), 403), + ): + self.wire( + call( + actor, + "post", + self.member_root + "preview/", + dict(user_id=self.member.pk), + ), + status, + ) + other = create_partner_program( + name="Чужая программа", solution_storage_mode="solution" + ) + path = f"/programs/{other.pk}/management/solutions/{self.solution.pk}/members/remove/preview/" + self.wire( + call(create_user(is_staff=True), "post", path, dict(user_id=self.member.pk)), + 404, + ) + self.wire(self.preview(user_id=9007199254740991), 404) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="legacy_project" + ) + self.assertEqual( + self.wire(self.preview(), 409)["code"], "solution_mode_unavailable" + ) + + def test_permissions_are_rechecked_when_manager_role_is_revoked(self): + command = self.removal_command() + self.program.managers.remove(self.manager) + self.wire(self.apply(command), 403) + self.assertTrue(self.solution.members.filter(user=self.member).exists()) + + def test_strict_body_query_and_exact_safe_integer_validation(self): + for body in ( + {}, + {"user_id": True}, + {"user_id": "1"}, + {"user_id": 0}, + {"user_id": 1.5}, + {"user_id": 2**53}, + {"user_id": self.member.pk, "project_id": 1}, + ): + self.wire( + call(self.manager, "post", self.member_root + "preview/", body), 400 + ) + self.wire( + call( + self.manager, + "post", + self.member_root + "preview/?all=true", + dict(user_id=self.member.pk), + ), + 400, + ) + for token in (None, True, 7, ""): + self.wire(self.apply(dict(user_id=self.member.pk, preview_token=token)), 400) + self.wire(self.apply(dict(user_id=self.member.pk)), 400) + + def test_case_and_member_history_coexist_without_rewriting_case_payload(self): + field = self.program.fields.get(pk=self.fields["select"]["id"]) + field.solution_options = ["Задача 1", "Задача 2"] + field.save() + data = solution_case_actions.preview( + self.program.pk, self.solution.pk, self.manager, dict(case_name="Задача 2") + ) + solution_case_actions.apply( + self.program.pk, + self.solution.pk, + self.manager, + dict(case_name="Задача 2", preview_token=data["preview_token"]), + ) + case = self.solution.administrative_changes.get() + original = call(self.manager, "get", self.history + str(case.pk) + "/").data + self.wire(self.apply(self.removal_command()), schema="MutationReceipt") + listing = self.wire( + call(self.manager, "get", self.history), schema="SolutionAdminChangePage" + ) + self.assertEqual( + [r["action"] for r in listing["results"]], ["remove_member", "change_case"] + ) + self.assertEqual( + call(self.manager, "get", self.history + str(case.pk) + "/").data, original + ) + self.assertNotIn("removed_member", original["change"]) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionMemberActionConcurrencyTests(MemberFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL program locks") + super().setUp() + + def race(self, commands): + # The shared observed-wait helper proves both worker backends are blocked + # by the controller's program lock before allowing either writer through. + from partner_programs.tests.test_solution_case_actions import ( + SolutionCaseActionConcurrencyTests, + ) + + return SolutionCaseActionConcurrencyTests.race(self, commands) + + def test_two_submitted_confirmations_remove_once_and_keep_original_snapshot(self): + self.assertEqual(self.submit().status_code, 200) + original = copy.deepcopy(self.solution.submission_snapshot) + command = self.removal_command() + + def remove(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + self.assertCountEqual( + self.race([remove, remove]), ["saved", "solution_action_conflict"] + ) + self.solution.refresh_from_db() + self.assertFalse(self.solution.members.filter(user=self.member).exists()) + self.assertEqual( + self.solution.administrative_changes.get().before_snapshot, original + ) + + def test_removal_and_submission_keep_one_version_and_coherent_roster(self): + command = self.removal_command() + submit = self.command() + version = self.solution.version + + def remove(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + def send(): + return submit_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **submit, + ) + + result = self.race([remove, send]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, version + 1) + if result[0] == "saved": + self.assertEqual(result, ["saved", "version_conflict"]) + self.assertEqual(self.solution.status, "draft") + self.assertEqual(self.solution.members.count(), 1) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertEqual(self.solution.status, "submitted") + self.assertEqual(len(self.solution.submission_snapshot["members"]), 2) + + def test_submitted_removal_and_score_update_preserve_scoring(self): + self.evaluation() + command = self.removal_command() + before = copy.deepcopy(self.solution.submission_snapshot) + + def remove(): + return actions.apply(self.program.pk, self.solution.pk, self.manager, command) + + def rate(): + self.score.value = "7" + self.score.save() + + result = self.race([remove, rate]) + self.score.refresh_from_db() + self.solution.refresh_from_db() + self.assertEqual(self.score.value, "7") + self.assertEqual(result[1], "saved") + self.assertTrue( + SolutionExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + if result[0] == "saved": + self.assertEqual( + self.solution.administrative_changes.get().before_snapshot, before + ) + self.assertFalse(self.solution.members.filter(user=self.member).exists()) + else: + self.assertEqual(result[0], "solution_action_conflict") + self.assertEqual(self.solution.submission_snapshot, before) diff --git a/partner_programs/tests/test_solution_participant_reminders.py b/partner_programs/tests/test_solution_participant_reminders.py new file mode 100644 index 00000000..148e1d00 --- /dev/null +++ b/partner_programs/tests/test_solution_participant_reminders.py @@ -0,0 +1,221 @@ +from unittest.mock import patch + +from django.core import mail +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from partner_programs.models import ( + ProgramAttentionReminder, + ProgramAttentionReminderBatch, +) +from partner_programs.services.attention_actions import deliver_reminder +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) + +TEAMS = "participants-without-team" +VISITS = "participants-without-visit" + + +@override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], + EXPERT_WORKSPACE_FRONTEND_URL="https://procollab.local", +) +class SolutionParticipantRemindersTests(TestCase): + def setUp(self): + self.manager = create_user() + self.program = create_partner_program( + solution_storage_mode="solution", solution_template="case_championship" + ) + self.program.managers.add(self.manager) + self.captain = self.registered() + self.solution = ProgramSolution.objects.create( + program=self.program, captain=self.captain + ) + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def registered(self, program=None, **fields): + user = create_user(**fields) + create_program_member(program or self.program, user=user) + return user + + def post(self, action, payload, category=TEAMS, program=None): + return self.client.post( + f"/programs/{(program or self.program).pk}/manager-overview/" + f"attention/{category}/{action}/", + payload, + format="json", + ) + + def preview(self, ids=None, category=TEAMS): + response = self.post( + "reminder-preview", + { + "selection": { + "mode": "all" if ids is None else "selected", + "ids": ids or [], + "excluded_ids": [], + } + }, + category, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + return response.data + + def confirm(self, preview, category=TEAMS): + response = self.post("reminders", {"token": preview["token"]}, category) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + return response.data + + def test_preview_uses_accepted_membership_and_real_program_scope(self): + member = self.registered() + ProgramSolutionMember.objects.create(solution=self.solution, user=member) + pending = self.registered() + SolutionInvitation.objects.create(solution=self.solution, recipient=pending) + without_team = self.registered() + historical = create_project(leader=without_team) + create_program_project(self.program, project=historical) + other_program = create_partner_program(solution_storage_mode="solution") + self.registered(other_program) + + result = self.preview() + + self.assertEqual(result["selected_count"], 2) + self.assertEqual(result["recipient_count"], 2) + self.assertCountEqual( + [row["user_id"] for row in result["recipients"]], + [pending.pk, without_team.pk], + ) + self.assertIn( + "Создайте решение или присоединитесь к команде.", result["preview_text"] + ) + self.assertNotIn("Создайте проект", result["preview_text"]) + self.assertFalse(ProgramAttentionReminderBatch.objects.exists()) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_template_competition_and_legacy_keep_their_participant_message(self): + self.registered() + for template, competitive in [ + ("case_championship", True), + ("hackathon", True), + ("hackathon", False), + ]: + with self.subTest(template=template, competitive=competitive): + self.program.solution_template = template + self.program.is_competitive = competitive + self.program.save(update_fields=["solution_template", "is_competitive"]) + self.assertIn("Создайте решение", self.preview()["preview_text"]) + + legacy = create_partner_program() + legacy.managers.add(self.manager) + user = self.registered(legacy) + response = self.post( + "reminder-preview", + {"selection": {"mode": "selected", "ids": [user.pk]}}, + program=legacy, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertIn("Создайте проект", response.data["preview_text"]) + self.assertNotIn("Создайте решение", response.data["preview_text"]) + + def test_confirm_is_idempotent_and_queues_only_after_commit(self): + user = self.registered() + preview = self.preview([user.pk]) + with patch("partner_programs.services.attention_actions.queue_batch") as queue: + with self.captureOnCommitCallbacks(execute=True): + result = self.confirm(preview) + queue.assert_not_called() + queue.assert_called_once() + self.assertEqual(self.confirm(preview), result) + queue.assert_called_once() + + self.assertEqual(result, {"created_count": 1, "skipped_count": 0}) + notice = ProgramAttentionReminder.objects.get() + self.assertEqual(notice.recipient_id, user.pk) + self.assertEqual(notice.source_ids, [user.pk]) + self.assertEqual(notice.assignment_ids, []) + self.assertEqual(notice.text, preview["preview_text"]) + self.assertEqual(notice.action_path, f"/office/program/{self.program.pk}/members") + + def test_member_joining_after_preview_is_excluded_at_confirmation(self): + user = self.registered() + preview = self.preview([user.pk]) + ProgramSolutionMember.objects.create(solution=self.solution, user=user) + + self.assertEqual(self.confirm(preview), {"created_count": 0, "skipped_count": 1}) + self.assertFalse(ProgramAttentionReminder.objects.exists()) + + def test_captain_creating_solution_after_confirmation_cancels_delivery(self): + user = self.registered() + self.confirm(self.preview([user.pk])) + notice = ProgramAttentionReminder.objects.get() + ProgramSolution.objects.create(program=self.program, captain=user) + + deliver_reminder(notice.pk) + + notice.refresh_from_db() + self.assertEqual(notice.delivery_status, "cancelled") + self.assertEqual(notice.attempts, 0) + self.assertEqual(len(mail.outbox), 0) + + def test_local_delivery_preserves_solution_text_path_and_exactly_one_email(self): + user = self.registered() + self.confirm(self.preview([user.pk])) + notice = ProgramAttentionReminder.objects.get() + + deliver_reminder(notice.pk) + deliver_reminder(notice.pk) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [user.email]) + self.assertIn("Создайте решение", mail.outbox[0].body) + self.assertNotIn("Создайте проект", mail.outbox[0].body) + self.assertIn( + f"https://procollab.local/office/program/{self.program.pk}/members", + mail.outbox[0].body, + ) + notice.refresh_from_db() + self.assertEqual(notice.delivery_status, "sent") + self.assertEqual(notice.attempts, 1) + self.assertIsNotNone(notice.emailed_at) + + def test_visit_reminder_retains_program_path_and_no_team_instruction(self): + user = self.registered() + preview = self.preview([user.pk], VISITS) + self.assertIn("Зайдите на PROCOLLAB", preview["preview_text"]) + self.assertNotIn("Создайте", preview["preview_text"]) + self.confirm(preview, VISITS) + self.assertEqual( + ProgramAttentionReminder.objects.get().action_path, + f"/office/program/{self.program.pk}", + ) + + def test_direct_solution_api_requires_manager_for_this_program(self): + participant, stranger = self.registered(), create_user() + for actor, status in [(None, 401), (participant, 403), (stranger, 403)]: + self.client.force_authenticate(actor) + for action in ["reminder-preview", "reminders"]: + with self.subTest(actor=actor, action=action): + self.assertEqual(self.post(action, {}).status_code, status) + + self.client.force_authenticate(self.manager) + other = create_partner_program(solution_storage_mode="solution") + self.assertEqual( + self.post("reminder-preview", {}, program=other).status_code, 403 + ) + self.client.force_authenticate(create_user(is_staff=True)) + self.assertEqual(self.preview([participant.pk])["recipient_count"], 1) + self.assertFalse(ProgramAttentionReminder.objects.exists()) diff --git a/partner_programs/tests/test_solution_program_routing.py b/partner_programs/tests/test_solution_program_routing.py new file mode 100644 index 00000000..ed1beb76 --- /dev/null +++ b/partner_programs/tests/test_solution_program_routing.py @@ -0,0 +1,47 @@ +"""Public routing metadata does not grant access to the program's private content.""" + +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from project_rates.tests.helpers import create_rate_expert + + +class SolutionProgramRoutingTests(TestCase): + def test_mode_is_explicit_for_member_expert_and_outsider_without_private_fields(self): + client = APIClient() + for mode in ("legacy_project", "solution"): + program = create_partner_program( + solution_storage_mode=mode, description="Закрытое описание" + ) + member = create_user() + create_program_member(program, user=member) + expert = create_rate_expert(program=program) + outsider = create_user() + for user in (member, expert, outsider): + with self.subTest(mode=mode, user=user.pk): + client.force_authenticate(user) + response = client.get(f"/programs/{program.pk}/") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["solution_storage_mode"], mode) + self.assertNotIn("solutions", response.data) + self.assertNotIn("submission_snapshot", response.data) + if user != member: + self.assertNotIn("description", response.data) + self.assertNotIn("links", response.data) + + def test_routing_metadata_is_read_only_on_both_serializer_variants(self): + from partner_programs.serializers.programs import ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ) + + for serializer in ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ): + self.assertTrue(serializer().fields["solution_storage_mode"].read_only) diff --git a/partner_programs/tests/test_solution_publication.py b/partner_programs/tests/test_solution_publication.py new file mode 100644 index 00000000..ede240ec --- /dev/null +++ b/partner_programs/tests/test_solution_publication.py @@ -0,0 +1,502 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier +from unittest import skipUnless + +from django.contrib import admin +from django.db import close_old_connections, connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase, TransactionTestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from files.models import UserFile +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramUserProfile, +) +from partner_programs.services.publishing import ( + publish_finished_program_projects, + publish_finished_program_solutions, + publish_solution_program, +) +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tasks import publish_finished_program_solutions_task +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from projects.models import Project + + +class PublicationFixture(SubmissionFixture): + def seed_publication(self): + self.ready() + self.now = timezone.now() + self.program.is_competitive = False + self.program.publish_projects_after_finish = True + self.program.datetime_finished = self.now - timezone.timedelta(days=1) + self.program.save( + update_fields=[ + "is_competitive", + "publish_projects_after_finish", + "datetime_finished", + ] + ) + self.public_url = f"/programs/{self.program.pk}/published-solutions/" + + def publish(self): + return publish_solution_program(self.program.pk, now=self.now) + + def other_solution(self, program=None, name="Еще одно решение"): + program = program or self.program + captain = create_user() + create_program_member(program, user=captain) + solution = ProgramSolution.objects.create( + program=program, captain=captain, name=name + ) + ProgramSolutionMember.objects.create(solution=solution, user=captain) + return solution + + def assert_schema(self, name, data): + from jsonschema import Draft202012Validator, FormatChecker + + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + {"components": spec["components"], "$ref": f"#/components/schemas/{name}"}, + format_checker=FormatChecker(), + ).validate(data) + + def read(self, solution=None, query=""): + suffix = f"{solution.pk}/" if solution else "" + response = APIClient().get(self.public_url + suffix + query) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response["Cache-Control"], "no-store") + self.assert_schema( + "PublishedSolution" if solution else "PublishedSolutionPage", response.data + ) + return response.data + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionPublicationTests(PublicationFixture, TestCase): + def setUp(self): + self.seed_publication() + + def test_publisher_marks_saved_noncompetitive_without_submit_or_other_changes(self): + self.solution.refresh_from_db() + before = self.solution.version, self.solution.updated_at, self.solution.status + files, projects = UserFile.objects.count(), Project.objects.count() + self.assertEqual(self.publish(), 1) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + self.assertEqual( + (self.solution.version, self.solution.updated_at, self.solution.status), + before, + ) + self.assertIsNone(self.solution.submitted_at) + self.assertIsNone(self.solution.submission_snapshot) + self.assertEqual(UserFile.objects.count(), files) + self.assertEqual(Project.objects.count(), projects) + + def test_no_flag_future_or_competitive_is_not_published(self): + original_end = self.program.datetime_finished + for changes in [ + dict(publish_projects_after_finish=False), + dict(datetime_finished=self.now + timezone.timedelta(seconds=1)), + dict(is_competitive=True), + ]: + PartnerProgram.objects.filter(pk=self.program.pk).update(**changes) + self.assertEqual(publish_finished_program_solutions(now=self.now), 0) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=True, + datetime_finished=original_end, + is_competitive=False, + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_finished=self.now + ) + self.assertEqual(self.publish(), 1) + + def test_repeated_and_late_publication_preserve_marker_after_flag_disabled(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=False + ) + self.assertEqual(self.publish(), 0) + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=True + ) + self.assertEqual(self.publish(), 1) + later = self.now + timezone.timedelta(days=2) + self.assertEqual(publish_finished_program_solutions(now=later), 0) + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=False, + datetime_finished=later, + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + self.assertEqual(self.read()["count"], 1) + self.read(self.solution) + + def test_partial_and_empty_materials_are_publishable_saved_work(self): + ProgramSolutionAnswer.objects.filter(solution=self.solution).delete() + self.assertEqual(self.publish(), 1) + data = self.read(self.solution) + self.assertEqual(data["answers"], []) + self.assertTrue(any(field["required"] for field in data["fields"])) + self.assertEqual(data["team"]["accepted_count"], 1) + + def test_get_is_read_only_and_does_not_publish_an_eligible_work(self): + self.assertEqual(self.read()["count"], 0) + response = APIClient().get(self.public_url + f"{self.solution.pk}/") + self.assertEqual(response.status_code, 404) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + + def test_public_materials_keep_zero_false_files_and_only_accepted_people(self): + member, pending = create_user(), create_user() + for user in [member, pending]: + create_program_member( + self.program, user=user, data={"private_phone": "registration-secret"} + ) + ProgramSolutionMember.objects.create( + solution=self.solution, user=member, role="Разработчик" + ) + SolutionInvitation.objects.create( + solution=self.solution, + recipient=pending, + motivational_letter="invitation-secret", + ) + file = self.upload() + self.assertEqual( + self.save_answers( + [dict(field_id=self.fields["file"]["id"], value=dict(url=file.pk))] + ).status_code, + 200, + ) + self.publish() + data = self.read(self.solution) + answers = {row["field_id"]: row["value"] for row in data["answers"]} + self.assertEqual(answers[self.fields["number"]["id"]], 0) + self.assertIs(answers[self.fields["checkbox"]["id"]], False) + self.assertEqual(answers[self.fields["file"]["id"]], {"url": file.pk}) + self.assertCountEqual( + [row["user"]["id"] for row in data["team"]["members"]], + [self.captain.pk, member.pk], + ) + self.assertEqual(sum(row["is_captain"] for row in data["team"]["members"]), 1) + self.assertEqual( + set(data), {"application", "published_at", "fields", "answers", "team"} + ) + encoded = json.dumps(data, ensure_ascii=False) + for secret in [ + self.captain.email, + member.email, + "registration-secret", + "invitation-secret", + "1990-01-01", + ]: + self.assertNotIn(secret, encoded) + self.assertEqual( + set(data["team"]["members"][0]["user"]), + {"id", "first_name", "last_name", "avatar_url"}, + ) + + def test_allowed_edit_after_publication_is_visible_and_stale_save_cannot_revoke(self): + stale = ProgramSolution.objects.get(pk=self.solution.pk) + version = stale.version + self.publish() + stale.name = "Обычная модельная правка" + stale.save() + stale.refresh_from_db() + self.assertEqual(stale.published_at, self.now) + self.assertEqual(stale.version, version) + self.assertEqual(self.read(self.solution)["application"]["name"], stale.name) + self.assertEqual(self.save_answers(name="Обновленное решение").status_code, 200) + self.assertEqual( + self.read(self.solution)["application"]["name"], "Обновленное решение" + ) + + def test_public_answers_do_not_expose_private_objects_or_file_metadata(self): + file = self.upload() + self.assertEqual( + self.save_answers( + [ + dict(field_id=self.fields["file"]["id"], value=dict(url=file.pk)), + dict( + field_id=self.fields["url"]["id"], + value="https://example.org/demo", + ), + ] + ).status_code, + 200, + ) + for kind in ["textarea", "number"]: + ProgramSolutionAnswer.objects.filter( + solution=self.solution, + field_id=self.fields[kind]["id"], + ).update(value={"private_email": "private-value@example.org"}) + ProgramSolutionAnswer.objects.filter( + solution=self.solution, + field_id=self.fields["file"]["id"], + ).update(value={"url": file.pk, "owner_email": "private-value@example.org"}) + ProgramSolutionAnswer.objects.filter( + solution=self.solution, + field_id=self.fields["url"]["id"], + ).update(value="javascript:alert(1)") + self.publish() + data = self.read(self.solution) + answers = {row["field_id"]: row["value"] for row in data["answers"]} + self.assertNotIn(self.fields["textarea"]["id"], answers) + self.assertNotIn(self.fields["number"]["id"], answers) + self.assertNotIn(self.fields["url"]["id"], answers) + self.assertEqual(answers[self.fields["file"]["id"]], {"url": file.pk}) + self.assertNotIn("private-value", json.dumps(data)) + + def test_owner_api_model_and_admin_cannot_assign_publication_timestamp(self): + response = self.save_answers(published_at=self.now.isoformat()) + self.assertEqual(response.status_code, 400) + self.solution.published_at = self.now + self.solution.save(update_fields=["published_at"]) + self.solution.refresh_from_db() + self.assertIsNone(self.solution.published_at) + self.assertFalse(ProgramSolution._meta.get_field("published_at").editable) + self.assertIn( + "published_at", + admin.site._registry[ProgramSolution].get_readonly_fields( + None, self.solution + ), + ) + + def test_list_count_order_search_pagination_and_safe_query(self): + second = self.other_solution(name="Другой прототип") + self.publish() + data = self.read(query="?limit=1") + self.assertEqual(data["count"], 2) + self.assertEqual( + data["results"][0]["application"]["solution_id"], self.solution.pk + ) + self.assertIn("offset=1", data["next"]) + page = APIClient().get(data["next"]) + self.assertEqual(page.status_code, 200) + self.assertEqual(page.data["results"][0]["application"]["solution_id"], second.pk) + self.assertIsNotNone(page.data["previous"]) + self.assertEqual(self.read(query="?search=Другой")["count"], 1) + for query in [ + "?limit=0", + "?limit=101", + "?offset=-1", + "?unknown=1", + "?submission=all", + ]: + response = APIClient().get(self.public_url + query) + self.assertEqual(response.status_code, 400, response.data) + self.assert_schema("Error", response.data) + + def test_unpublished_foreign_unknown_unsafe_are_hidden_for_every_role(self): + unpublished = self.other_solution() + self.publish() + later = self.other_solution() + for actor in [None, self.captain, self.manager, create_user()]: + for identifier in [later.pk, MAX_ID, MAX_ID + 1, 0]: + response = call(actor, "get", self.public_url + f"{identifier}/") + self.assertEqual(response.status_code, 404) + self.assert_schema("Error", response.data) + other = create_partner_program( + solution_storage_mode="solution", is_competitive=False + ) + foreign_url = self.public_url.replace(f"/{self.program.pk}/", f"/{other.pk}/") + self.assertEqual( + APIClient().get(foreign_url + f"{unpublished.pk}/").status_code, 404 + ) + + def test_legacy_is_hidden_and_marked_competitive_draft_is_not_exposed(self): + self.publish() + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=True) + self.assertEqual(self.read()["count"], 0) + self.assertEqual( + APIClient().get(self.public_url + f"{self.solution.pk}/").status_code, 404 + ) + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="legacy_project" + ) + self.assertEqual(APIClient().get(self.public_url).status_code, 404) + self.assertEqual( + APIClient().get(self.public_url + f"{self.solution.pk}/").status_code, 404 + ) + + def test_private_read_and_public_write_permissions_are_unchanged(self): + self.publish() + self.assertEqual(APIClient().get(self.url).status_code, 401) + self.assertEqual(call(create_user(), "get", self.url).status_code, 404) + self.assertEqual( + APIClient().post(self.public_url, {}, format="json").status_code, 405 + ) + self.assertEqual( + APIClient() + .post(self.public_url + f"{self.solution.pk}/", {}, format="json") + .status_code, + 405, + ) + response = APIClient().get(self.public_url + f"{self.solution.pk}/?unknown=1") + self.assertEqual(response.status_code, 400) + + def test_snapshot_is_allowlisted_and_does_not_read_later_live_values(self): + self.program.is_competitive = True + self.program.save(update_fields=["is_competitive"]) + self.assertEqual(self.submit().status_code, 200) + self.solution.refresh_from_db() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + name = snapshot["name"] + snapshot["members"][0]["user"]["email"] = "snapshot-private@example.com" + snapshot["form"]["fields"][0]["internal"] = "snapshot-private" + ProgramSolution.objects.filter(pk=self.solution.pk).update( + name="Другое текущее имя", submission_snapshot=snapshot + ) + ProgramSolutionAnswer.objects.filter( + solution=self.solution, field_id=self.fields["number"]["id"] + ).update(value=999) + PartnerProgramField.objects.filter(pk=self.fields["number"]["id"]).update( + label="Новая подпись" + ) + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.publish() + data = self.read(self.solution) + self.assertEqual(data["application"]["name"], name) + self.assertNotIn("snapshot-private", json.dumps(data)) + self.assertNotIn("Новая подпись", json.dumps(data, ensure_ascii=False)) + self.assertEqual( + {row["field_id"]: row["value"] for row in data["answers"]}[ + self.fields["number"]["id"] + ], + 0, + ) + self.assertEqual(self.read(query="?search=" + name)["count"], 1) + + def test_legacy_publisher_does_not_publish_projects_linked_to_solution_program(self): + project = create_project() + profile_project = create_project(leader=self.captain) + create_program_project(self.program, project=project) + # Историческая анкета могла сохранять Project до включения нового режима. + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, + user=self.captain, + ).update(project=profile_project) + legacy = create_partner_program( + publish_projects_after_finish=True, + datetime_finished=self.now - timezone.timedelta(days=1), + ) + legacy_link, legacy_profile = create_project(), create_project() + create_program_project(legacy, project=legacy_link) + create_program_member(legacy, user=legacy_profile.leader, project=legacy_profile) + self.assertEqual(publish_finished_program_projects(now=self.now), 2) + for private in [project, profile_project]: + private.refresh_from_db() + self.assertFalse(private.is_public) + for public in [legacy_link, legacy_profile]: + public.refresh_from_db() + self.assertTrue(public.is_public) + self.assertEqual(self.publish(), 1) + self.assertEqual(Project.objects.count(), 4) + + def test_daily_task_publishes_once_without_project_task_result_change(self): + self.assertEqual(publish_finished_program_solutions_task.run(), 1) + self.assertEqual(publish_finished_program_solutions_task.run(), 0) + from procollab.celery import app + + self.assertEqual( + app.conf.beat_schedule["publish_finished_program_solutions"]["schedule"], + app.conf.beat_schedule["publish_finished_program_projects"]["schedule"], + ) + + def test_list_and_detail_query_count_does_not_grow_per_solution(self): + self.publish() + with CaptureQueriesContext(connection) as initial: + self.read() + for index in range(8): + self.other_solution(name=f"Решение {index}") + self.publish() + with CaptureQueriesContext(connection) as expanded: + result = self.read() + self.assertEqual(len(result["results"]), 9) + self.assertEqual(len(initial), len(expanded)) + self.assertLessEqual(len(expanded), 6) + with CaptureQueriesContext(connection) as detail_queries: + self.read(self.solution) + self.assertLessEqual(len(detail_queries), 6) + self.assertFalse(any("project_rates_" in row["sql"] for row in detail_queries)) + + +@skipUnless(connection.vendor == "postgresql", "Requires PostgreSQL locks") +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionPublicationConcurrencyTests(PublicationFixture, TransactionTestCase): + def test_two_publishers_keep_one_marker_and_one_count(self): + self.seed_publication() + barrier = Barrier(2) + + def publish(): + close_old_connections() + try: + barrier.wait(timeout=10) + return publish_solution_program(self.program.pk, now=self.now) + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: publish(), range(2))) + self.assertCountEqual(results, [0, 1]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionPublicationMigrationTests(TransactionTestCase): + def test_existing_solution_remains_private_after_migration(self): + before = [("partner_programs", "0032_alter_programsolution_options")] + after = [("partner_programs", "0033_solution_publication")] + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + program, captain = ( + create_partner_program(solution_storage_mode="solution"), + create_user(), + ) + try: + executor.migrate(before) + old_apps = executor.loader.project_state(before).apps + solution = old_apps.get_model( + "partner_programs", "ProgramSolution" + ).objects.create(program_id=program.pk, captain_id=captain.pk) + executor = MigrationExecutor(connection) + executor.migrate(after) + current = ( + executor.loader.project_state(after) + .apps.get_model("partner_programs", "ProgramSolution") + .objects.get(pk=solution.pk) + ) + self.assertIsNone(current.published_at) + self.assertEqual(current.status, "draft") + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/partner_programs/tests/test_solution_registration_actions.py b/partner_programs/tests/test_solution_registration_actions.py new file mode 100644 index 00000000..1be7a639 --- /dev/null +++ b/partner_programs/tests/test_solution_registration_actions.py @@ -0,0 +1,609 @@ +from time import time +from unittest.mock import patch + +from django.core import signing +from django.core.exceptions import ValidationError +from django.db import connection, transaction +from django.db.migrations.executor import MigrationExecutor +from django.db.models.deletion import ProtectedError +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone + +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from partner_programs.services import solution_archive_actions as archive +from partner_programs.services import solution_registration_actions as actions +from partner_programs.solution_models import ( + ProgramSolutionMember, + SolutionAdministrativeChange, + SolutionInvitation, + SolutionRegistrationRemoval, +) +from partner_programs.tests.helpers import ( + create_user, + create_program_member, + create_partner_program, + create_program_project, +) +from partner_programs.tests.test_solution_archive_actions import ArchiveFixture +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_member_actions import FAST_HASHERS +from projects.models import Project +from users.models import CustomUser + + +class RegistrationFixture(ArchiveFixture): + def setUp(self): + super().setUp() + self.removal_root = ( + f"/programs/{self.program.pk}/management/registrations/remove/" + ) + self.audit_root = ( + f"/programs/{self.program.pk}/management/registrations/removals/" + ) + + def registration_ids(self, *users): + return list( + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, user__in=users or [self.member] + ) + .order_by("pk") + .values_list("pk", flat=True) + ) + + def removal_preview(self, ids=None, actor=None): + return call( + actor or self.manager, + "post", + self.removal_root + "preview/", + dict(registration_ids=self.registration_ids() if ids is None else ids), + ) + + def removal_command(self, ids=None): + ids = self.registration_ids() if ids is None else ids + response = self.wire(self.removal_preview(ids)) + self.assertTrue(response["can_apply"], response) + return dict(registration_ids=ids, preview_token=response["preview_token"]) + + def remove(self, command=None, actor=None): + return call( + actor or self.manager, + "post", + self.removal_root + "apply/", + self.removal_command() if command is None else command, + ) + + def wire(self, response, status=200, schema="SolutionRegistrationRemovalPreview"): + return super().wire(response, status, schema) + + def audit(self, audit_id): + return self.wire( + call(self.manager, "get", self.audit_root + str(audit_id) + "/"), + schema="SolutionRegistrationRemovalAuditDetail", + ) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionRegistrationActionTests(RegistrationFixture, TestCase): + def test_preview_read_only_binds_profile_ids_and_minimal_snapshot(self): + self.evaluation() + before = self.retained() + result = self.wire(self.removal_preview()) + self.assertEqual(result["deleted_count"], 1) + self.assertEqual(result["removed_member_count"], 1) + self.assertEqual(result["effects"][0]["effect"], "remove_members") + self.assertEqual(result["score_count"], 1) + self.assertEqual(result["removed_assignments_count"], 0) + proof = signing.loads(result["preview_token"], salt=actions.SALT) + self.assertEqual(proof["registration_ids"], [self.registration.pk]) + self.assertEqual(proof["actor"], self.manager.pk) + self.assertEqual(proof["program"], self.program.pk) + self.assertEqual(set(result["registrations"][0]), {"registration_id", "user"}) + self.assertEqual(self.retained(), before) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + + def test_submitted_member_removal_retains_answers_scores_assignments_processed_invite_and_user( + self, + ): + self.evaluation() + before = self.retained() + response = self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + self.solution.refresh_from_db() + after = self.retained() + for key in ("answers", "scores", "assignments", "invitations", "projects"): + self.assertEqual(after[key], before[key]) + self.assertFalse( + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).exists() + ) + self.assertTrue(CustomUser.objects.filter(pk=self.member.pk).exists()) + self.assertEqual(self.solution.status, "submitted") + self.assertEqual(self.solution.submitted_at, before["solution"]["submitted_at"]) + self.assertEqual(self.solution.version, before["solution"]["version"] + 1) + self.assertEqual( + [m["user"]["id"] for m in self.solution.submission_snapshot["members"]], + [self.captain.pk], + ) + change = self.solution.administrative_changes.get() + self.assertEqual( + change.before_snapshot, before["solution"]["submission_snapshot"] + ) + self.assertEqual(change.after_snapshot, self.solution.submission_snapshot) + self.assertEqual(change.removed_members[0]["user"]["id"], self.member.pk) + self.assertEqual(change.registration_removal_id, response["audit_id"]) + self.assertEqual(call(self.member, "get", self.url).status_code, 404) + self.assertEqual(call(self.captain, "get", self.url).status_code, 200) + audit = self.audit(response["audit_id"]) + self.assertEqual(audit["changes"][0]["action"], "remove_registrations") + self.wire( + call(self.manager, "get", self.history + str(change.pk) + "/"), + schema="SolutionAdminChangeDetail", + ) + + def test_captain_and_member_selection_archives_once_preserving_roster_and_submission( + self, + ): + SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user(), role="Дизайнер" + ) + self.evaluation() + before = self.retained() + ids = self.registration_ids(self.captain, self.member) + response = self.wire( + self.remove(self.removal_command(ids)), + schema="SolutionRegistrationRemovalReceipt", + ) + self.solution.refresh_from_db() + after = self.retained() + self.assertEqual(response["deleted_count"], 2) + self.assertEqual(len(response["items"]), 1) + self.assertEqual(self.solution.archive_reason, "registration_removed") + for key in ("answers", "members", "scores", "projects"): + self.assertEqual(after[key], before[key]) + self.assertEqual( + self.solution.submission_snapshot, before["solution"]["submission_snapshot"] + ) + self.assertFalse(self.solution.expert_assignments.exists()) + self.assertFalse(self.solution.invitations.filter(status="pending").exists()) + self.assertTrue(self.solution.invitations.filter(status="accepted").exists()) + self.assertEqual(call(self.captain, "get", self.url).status_code, 200) + event = self.audit(response["audit_id"])["changes"][0] + self.assertEqual(event["reason"], "registration_removed") + self.assertEqual(event["registration_removal_id"], response["audit_id"]) + + def test_draft_captain_archive_without_artificial_submission(self): + response = self.wire( + self.remove(self.removal_command(self.registration_ids(self.captain))), + schema="SolutionRegistrationRemovalReceipt", + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submitted_at) + self.assertIsNone(self.solution.submission_snapshot) + self.assertIsNotNone(self.solution.archive_snapshot) + self.assertEqual( + self.audit(response["audit_id"])["changes"][0]["action"], "archive" + ) + + def test_pending_only_revoked_with_revision_and_audit_but_roster_unchanged(self): + user = create_user() + registration = create_program_member(self.program, user=user) + pending = SolutionInvitation.objects.create( + solution=self.solution, recipient=user, role="Дизайнер" + ) + self.evaluation() + before = self.retained() + response = self.wire( + self.remove(self.removal_command([registration.pk])), + schema="SolutionRegistrationRemovalReceipt", + ) + after = self.retained() + self.assertEqual(after["members"], before["members"]) + self.assertEqual( + after["solution"]["submission_snapshot"], + before["solution"]["submission_snapshot"], + ) + self.assertFalse(SolutionInvitation.objects.filter(pk=pending.pk).exists()) + change = self.solution.administrative_changes.get() + self.assertEqual(change.removed_members, []) + self.assertEqual(change.revoked_invitation_ids, [pending.pk]) + self.assertEqual(self.audit(response["audit_id"])["audit"]["deleted_count"], 1) + + def test_registration_without_solution_keeps_global_audit(self): + user = create_user() + registration = create_program_member(self.program, user=user) + command = self.removal_command([registration.pk]) + response = self.wire( + self.remove(command), schema="SolutionRegistrationRemovalReceipt" + ) + self.assertEqual(response["items"], []) + audit = self.audit(response["audit_id"]) + self.assertEqual(audit["changes"], []) + self.assertEqual( + audit["audit"]["registrations"][0]["registration_id"], registration.pk + ) + self.wire( + call(self.manager, "get", self.audit_root), + schema="SolutionRegistrationRemovalAuditPage", + ) + self.wire(self.remove(command), 409) + self.assertEqual(SolutionRegistrationRemoval.objects.count(), 1) + + def test_person_display_changes_invalidate_preview_without_a_solution(self): + user = create_user() + registration = create_program_member(self.program, user=user) + command = self.removal_command([registration.pk]) + CustomUser.objects.filter(pk=user.pk).update(first_name="Новое имя") + self.wire(self.remove(command), 409) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=registration.pk).exists() + ) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + + def test_existing_archived_roster_snapshots_and_history_unchanged(self): + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + before = self.retained() + events = list(SolutionAdministrativeChange.objects.values()) + response = self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + after = self.retained() + for key in ( + "solution", + "members", + "answers", + "invitations", + "assignments", + "scores", + "projects", + ): + self.assertEqual(after[key], before[key]) + self.assertEqual(list(SolutionAdministrativeChange.objects.values()), events) + self.assertEqual(response["items"], []) + + def test_other_program_registration_and_legacy_project_rows_unchanged(self): + other = create_partner_program() + registration = create_program_member(other, user=self.member) + link = create_program_project(other) + before = list(Project.objects.order_by("pk").values()) + original = ( + PartnerProgramUserProfile.objects.filter(pk=registration.pk).values().get() + ) + self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + self.assertEqual(list(Project.objects.order_by("pk").values()), before) + self.assertEqual( + PartnerProgramUserProfile.objects.filter(pk=registration.pk).values().get(), + original, + ) + self.assertTrue(other.program_projects.filter(pk=link.pk).exists()) + + def test_mixed_legacy_profile_link_blocks_whole_selection(self): + link = create_program_project(self.program) + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).update( + project=link.project + ) + before = self.retained() + self.assertEqual( + self.wire(self.removal_preview(), 409)["code"], "legacy_application_conflict" + ) + self.assertEqual(self.retained(), before) + + def test_legacy_captain_link_without_profile_project_also_blocks(self): + link = create_program_project(self.program) + Project.objects.filter(pk=link.project_id).update(leader=self.member) + self.assertEqual( + self.wire(self.removal_preview(), 409)["code"], "legacy_application_conflict" + ) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).exists() + ) + + def test_legacy_program_rejected_without_mutation(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_storage_mode="legacy_project" + ) + before = self.retained() + self.assertEqual( + self.wire(self.removal_preview(), 409)["code"], "solution_mode_unavailable" + ) + self.assertEqual(self.retained(), before) + + def test_replay_wrong_actor_expiry_tampered_and_stale_are_atomic(self): + command = self.removal_command() + before = self.retained() + self.wire(self.remove({**command, "preview_token": "invalid"}), 409) + with patch("django.core.signing.time.time", return_value=time() + 601): + self.wire(self.remove(command), 409) + other = create_user(is_staff=True) + self.wire(self.remove(command, other), 409) + self.assertEqual(self.retained(), before) + self.wire(self.remove(command), schema="SolutionRegistrationRemovalReceipt") + saved = self.retained() + self.wire(self.remove(command), 409) + self.assertEqual(self.retained(), saved) + + def test_registration_data_or_score_changes_invalidate_confirmation(self): + self.evaluation() + command = self.removal_command() + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).update( + partner_program_data={"new": "value"} + ) + self.wire(self.remove(command), 409) + command = self.removal_command() + self.score.value = "7" + self.score.save() + self.wire(self.remove(command), 409) + self.assertTrue(self.solution.members.filter(user=self.member).exists()) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + + def test_all_effects_and_profile_deletion_roll_back_if_audit_or_delete_fails(self): + command = self.removal_command() + before = self.retained() + with patch.object( + SolutionAdministrativeChange.objects, + "create", + side_effect=RuntimeError("audit unavailable"), + ): + with self.assertRaises(RuntimeError): + actions.apply(self.program.pk, self.manager, command) + self.assertEqual(self.retained(), before) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + + def test_profile_signal_failure_rolls_back_captain_archive_and_all_audits(self): + from django.db.models.signals import pre_delete + + command = self.removal_command(self.registration_ids(self.captain)) + before = self.retained() + + def fail(sender, instance, **kwargs): + raise RuntimeError("registration delete unavailable") + + pre_delete.connect(fail, sender=PartnerProgramUserProfile, weak=False) + try: + with self.assertRaises(RuntimeError): + actions.apply(self.program.pk, self.manager, command) + finally: + pre_delete.disconnect(fail, sender=PartnerProgramUserProfile) + self.assertEqual(self.retained(), before) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + + def test_permission_scope_and_revocation_checked_again(self): + self.wire(self.removal_preview(actor=self.member), 403) + foreign = create_program_member(create_partner_program(), user=self.member) + self.wire(self.removal_preview([foreign.pk]), 404) + command = self.removal_command() + self.program.managers.remove(self.manager) + self.wire(self.remove(command), 403) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).exists() + ) + + def test_strict_requests_unknown_query_and_unsafe_ids(self): + for data in [ + {"registration_ids": []}, + {"registration_ids": [self.registration.pk] * 2}, + {"registration_ids": [True]}, + {"registration_ids": [2**53]}, + {"user_id": self.member.pk}, + {"registration_ids": [self.registration.pk], "project_id": 1}, + ]: + self.wire( + call(self.manager, "post", self.removal_root + "preview/", data), 400 + ) + self.wire( + call( + self.manager, + "post", + self.removal_root + "preview/?all=true", + dict(registration_ids=self.registration_ids()), + ), + 400, + ) + self.wire( + call( + self.manager, + "post", + self.removal_root + "apply/", + dict(registration_ids=self.registration_ids()), + ), + 400, + ) + + def test_audit_read_scope_pagination_and_immutable_model_queryset_admin(self): + response = self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + audit = SolutionRegistrationRemoval.objects.get(pk=response["audit_id"]) + self.wire(call(self.member, "get", self.audit_root), 403) + other = create_partner_program(solution_storage_mode="solution") + other.managers.add(self.manager) + self.wire( + call( + self.manager, + "get", + self.audit_root.replace(f"/{self.program.pk}/", f"/{other.pk}/") + + str(audit.pk) + + "/", + ), + 404, + ) + for query in [dict(limit=101), dict(offset=-1), dict(all=True)]: + self.wire(call(self.manager, "get", self.audit_root, query), 400) + with self.assertRaises(ValidationError): + audit.save() + with self.assertRaises(ValidationError): + audit.delete() + with self.assertRaises(ValidationError): + SolutionRegistrationRemoval.objects.filter(pk=audit.pk).update( + deleted_count=2 + ) + with self.assertRaises(ValidationError): + SolutionRegistrationRemoval.objects.filter(pk=audit.pk).delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + self.program.delete() + from django.contrib import admin + from partner_programs.solution_admin import SolutionRegistrationRemovalAdmin + + model_admin = SolutionRegistrationRemovalAdmin( + SolutionRegistrationRemoval, admin.site + ) + self.assertFalse(model_admin.has_add_permission(None)) + self.assertFalse(model_admin.has_change_permission(None, audit)) + self.assertFalse(model_admin.has_delete_permission(None, audit)) + + def test_closed_program_creation_switch_and_missing_current_form_do_not_block_exception( + self, + ): + self.evaluation() + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False, + solution_template=None, + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + + def test_manager_expert_and_auth_roles_survive_registration_deletion(self): + self.evaluation() + manager_registration = create_program_member(self.program, user=self.manager) + expert_registration = create_program_member(self.program, user=self.expert) + ids = [self.registration.pk, manager_registration.pk, expert_registration.pk] + before = list( + CustomUser.objects.filter( + pk__in=[self.member.pk, self.manager.pk, self.expert.pk] + ) + .order_by("pk") + .values() + ) + scores = list(self.solution.scores.values()) + assignments = list(self.solution.expert_assignments.values()) + response = self.wire( + self.remove(self.removal_command(ids)), + schema="SolutionRegistrationRemovalReceipt", + ) + self.assertEqual(response["deleted_count"], 3) + self.assertTrue(self.program.is_manager(self.manager)) + self.assertTrue(self.expert.expert.programs.filter(pk=self.program.pk).exists()) + self.assertEqual(list(self.solution.scores.values()), scores) + self.assertEqual(list(self.solution.expert_assignments.values()), assignments) + self.assertEqual( + list( + CustomUser.objects.filter( + pk__in=[self.member.pk, self.manager.pk, self.expert.pk] + ) + .order_by("pk") + .values() + ), + before, + ) + + def test_two_selected_members_create_one_grouped_revision_and_keep_captain(self): + user = create_user() + registration = create_program_member(self.program, user=user) + ProgramSolutionMember.objects.create( + solution=self.solution, user=user, role="Дизайнер" + ) + self.evaluation() + response = self.wire( + self.remove(self.removal_command([self.registration.pk, registration.pk])), + schema="SolutionRegistrationRemovalReceipt", + ) + self.solution.refresh_from_db() + self.assertEqual(len(response["items"]), 1) + self.assertEqual(self.solution.members.count(), 1) + self.assertEqual(len(self.solution.submission_snapshot["members"]), 1) + self.assertEqual(self.solution.administrative_changes.count(), 1) + self.assertEqual( + len(self.solution.administrative_changes.get().removed_members), 2 + ) + + def test_noncompetitive_draft_keeps_answers_and_has_no_submission(self): + PartnerProgram.objects.filter(pk=self.program.pk).update(is_competitive=False) + before = self.retained() + self.wire(self.remove(), schema="SolutionRegistrationRemovalReceipt") + after = self.retained() + self.assertEqual(after["answers"], before["answers"]) + self.assertEqual(after["solution"]["status"], "draft") + self.assertIsNone(after["solution"]["submission_snapshot"]) + self.assertIsNone(self.solution.administrative_changes.get().before_snapshot) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionRegistrationConcurrencyTests(RegistrationFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL locks") + super().setUp() + + def race(self, commands): + from partner_programs.tests.test_solution_case_actions import ( + SolutionCaseActionConcurrencyTests, + ) + + return SolutionCaseActionConcurrencyTests.race(self, commands) + + def test_two_confirmations_produce_one_profile_deletion_and_revision(self): + self.evaluation() + command = self.removal_command() + self.solution.refresh_from_db() + before = self.solution.version + + def remove(): + return actions.apply(self.program.pk, self.manager, command) + + self.assertCountEqual( + self.race([remove, remove]), ["saved", "solution_action_conflict"] + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, before + 1) + self.assertEqual(SolutionRegistrationRemoval.objects.count(), 1) + self.assertEqual(self.solution.scores.count(), 1) + self.assertEqual(self.solution.expert_assignments.count(), 1) + + def test_captain_removal_and_detach_produce_one_archive(self): + command = self.removal_command(self.registration_ids(self.captain)) + detach = self.archive_command() + + def remove(): + return actions.apply(self.program.pk, self.manager, command) + + def withdraw(): + return archive.apply(self.program.pk, self.manager, detach) + + result = self.race([remove, withdraw]) + self.solution.refresh_from_db() + if result[0] == "saved": + self.assertEqual(result, ["saved", "solution_action_conflict"]) + self.assertEqual(SolutionRegistrationRemoval.objects.count(), 1) + else: + self.assertEqual(result, ["solution_action_conflict", "saved"]) + self.assertEqual(SolutionRegistrationRemoval.objects.count(), 0) + self.assertEqual(self.solution.administrative_changes.count(), 1) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionRegistrationMigrationTests(RegistrationFixture, TransactionTestCase): + def test_additive_migration_preserves_archived_work_and_preceding_history(self): + self.evaluation() + self.wire( + self.archive_apply(self.archive_command()), schema="SolutionArchiveReceipt" + ) + before = self.retained() + executor = MigrationExecutor(connection) + leaf = executor.loader.graph.leaf_nodes() + old = [("partner_programs", "0037_solution_archive_retention")] + try: + executor.migrate(old) + historical = executor.loader.project_state(old).apps + history_model = historical.get_model( + "partner_programs", "SolutionAdministrativeChange" + ) + columns = [f.attname for f in history_model._meta.fields] + history = list(history_model.objects.order_by("pk").values(*columns)) + executor = MigrationExecutor(connection) + executor.migrate(leaf) + self.assertEqual(self.retained(), before) + self.assertEqual( + list( + SolutionAdministrativeChange.objects.order_by("pk").values(*columns) + ), + history, + ) + self.assertFalse(SolutionRegistrationRemoval.objects.exists()) + finally: + MigrationExecutor(connection).migrate(leaf) diff --git a/partner_programs/tests/test_solution_role_widget.py b/partner_programs/tests/test_solution_role_widget.py new file mode 100644 index 00000000..16cbca51 --- /dev/null +++ b/partner_programs/tests/test_solution_role_widget.py @@ -0,0 +1,299 @@ +import json + +from django.db import connection +from django.test import TestCase, override_settings +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) +from partner_programs.tests.test_solution_forms import call +from project_rates.models import Criteria +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_expert +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionRoleWidgetTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.widget_url = f"/programs/{self.program.pk}/analytics-widget/" + + def widget(self, user=None): + response = call(user or self.captain, "get", self.widget_url) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RoleWidget", response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertEqual(response.data["storage_mode"], "solution") + self.assertNotIn("participant_project", json.dumps(response.data)) + self.assertNotIn('"scores"', json.dumps(response.data)) + self.assertNotIn('"experts"', json.dumps(response.data)) + return response.data + + def test_participant_reads_own_snapshot_name_case_and_no_live_or_foreign_project( + self, + ): + frozen_name = self.solution.submission_snapshot["name"] + ProgramSolution.objects.filter(pk=self.solution.pk).update(name="Другое live имя") + self.program.fields.filter(name="case").update( + name="retired_case", purpose="other" + ) + data = self.widget()["participant"] + self.assertEqual( + data["application"], + dict( + kind="solution", + program_id=self.program.pk, + solution_id=self.solution.pk, + name=frozen_name, + ), + ) + self.assertTrue(data["case_provided"]) + self.assertEqual(data["case_name"], "Задача 1") + self.assertEqual(data["stage"], "review") + + def test_accepted_members_share_application_and_pending_is_not_membership(self): + draft = self.another_solution(submitted=False) + member, pending = create_user(), create_user() + for user in [member, pending]: + create_program_member(self.program, user=user) + ProgramSolutionMember.objects.create( + solution=draft, user=member, role="Разработчик" + ) + SolutionInvitation.objects.create( + solution=draft, recipient=pending, role="Дизайнер" + ) + leader = self.widget(draft.captain)["participant"] + self.assertEqual(self.widget(member)["participant"], leader) + self.assertEqual(leader["application"]["solution_id"], draft.pk) + self.assertIsNone(leader["case_name"]) + self.assertEqual(leader["stage"], "not_submitted") + self.assertIsNone(self.widget(pending)["participant"]["application"]) + + def test_organizer_counts_solutions_and_distinct_accepted_team_not_project_or_pending( + self, + ): + draft = self.another_solution(submitted=False) + member, pending, legacy_only = [create_user() for _ in range(3)] + for user in [member, pending, legacy_only]: + create_program_member(self.program, user=user) + ProgramSolutionMember.objects.create(solution=draft, user=member) + SolutionInvitation.objects.create(solution=draft, recipient=pending, role="Роль") + # Historical linkage is deliberately present; it is not a solution team. + project = create_project(leader=legacy_only) + PartnerProgramProject.objects.bulk_create( + [PartnerProgramProject(partner_program=self.program, project=project)] + ) + data = self.widget(self.manager)["organizer"] + self.assertEqual( + data, + dict( + participants=5, + solutions=2, + submitted_solutions=1, + participants_without_solution=2, + ), + ) + self.assertNotIn("projects", data) + + def test_expert_counts_own_complete_zero_false_and_unchanged_fact_after_deadline( + self, + ): + self.assertNotEqual(self.expert.pk, self.expert.expert.pk) + self.assertEqual(self.widget(self.expert)["expert"]["remaining"], 1) + response = self.rate() + self.assertEqual(response.status_code, 200, response.data) + self.program.datetime_evaluation_ends = timezone.now() - timezone.timedelta( + hours=1 + ) + self.program.save(update_fields=["datetime_evaluation_ends"]) + data = self.widget(self.expert)["expert"] + self.assertEqual(data["assigned"], 1) + self.assertEqual(data["remaining"], 0) + self.assertIsNotNone(data["evaluation_ends"]) + self.assertEqual(self.widget()["participant"]["stage"], "evaluated") + + def test_other_expert_scores_do_not_complete_personal_work_or_required_quota(self): + self.program.required_evaluators = 2 + self.program.save(update_fields=["required_evaluators"]) + SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.second.expert, + ) + self.assertEqual(self.rate().status_code, 200) + self.assertEqual(self.widget(self.expert)["expert"]["remaining"], 0) + self.assertEqual(self.widget(self.second)["expert"]["remaining"], 1) + self.assertEqual(self.widget()["participant"]["stage"], "review") + self.assertEqual(self.rate(actor=self.second).status_code, 200) + self.assertEqual(self.widget()["participant"]["stage"], "evaluated") + + def test_draft_not_ready_assignment_stays_in_expert_remaining(self): + draft = self.another_solution(submitted=False) + # Supported writes reject not-ready work; this models a historical row. + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=self.program, + solution=draft, + expert=self.expert.expert, + ) + ] + ) + self.assertEqual(self.rate().status_code, 200) + data = self.widget(self.expert)["expert"] + self.assertEqual((data["assigned"], data["remaining"]), (2, 1)) + + def test_open_stage_uses_existing_first_score_rule_with_inapplicable_personal_counts( + self, + ): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + self.assertEqual(self.widget()["participant"]["stage"], "submitted") + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=self.criteria[0], value="0" + ) + self.assertEqual(self.widget()["participant"]["stage"], "evaluated") + data = self.widget(self.expert)["expert"] + self.assertEqual(data["mode"], "open") + self.assertIsNone(data["assigned"]) + self.assertIsNone(data["remaining"]) + + def test_noncompetitive_saved_solution_does_not_require_submission(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + draft = self.another_solution(submitted=False) + self.assertEqual( + self.widget(draft.captain)["participant"]["stage"], "not_applicable" + ) + self.assertIsNone(self.widget(self.manager)["organizer"]["submitted_solutions"]) + self.assertIsNone(self.widget(self.expert)["expert"]["assigned"]) + draft.refresh_from_db() + self.assertEqual(draft.status, "draft") + + def test_no_criteria_never_completes_a_submitted_assignment(self): + Criteria.objects.filter(partner_program=self.program).delete() + data = self.widget(self.expert)["expert"] + self.assertEqual((data["assigned"], data["remaining"]), (1, 1)) + self.assertEqual(self.widget()["participant"]["stage"], "review") + + def test_ambiguous_owned_solutions_return_existing_409_detail_without_first_match( + self, + ): + draft = self.another_solution(submitted=False) + ProgramSolutionMember.objects.bulk_create( + [ProgramSolutionMember(solution=draft, user=self.captain)] + ) + response = call(self.captain, "get", self.widget_url) + self.assertEqual(response.status_code, 409) + self.assert_schema("WidgetError", response.data) + self.assertEqual(set(response.data), {"detail"}) + + def test_role_priority_scope_and_client_claims_do_not_reveal_other_work(self): + create_program_member(self.program, user=self.expert) + self.assertEqual(self.widget(self.expert)["role"], "expert") + self.program.managers.add(self.expert) + self.assertEqual(self.widget(self.expert)["role"], "organizer") + outsider = create_user(is_staff=True) + response = call( + outsider, + "get", + self.widget_url + + f"?role=organizer&user_id={self.captain.pk}&solution_id={self.solution.pk}", + ) + self.assertEqual(response.status_code, 403) + self.assert_schema("WidgetError", response.data) + self.assertEqual(APIClient().get(self.widget_url).status_code, 401) + + def test_foreign_assignments_and_expert_identity_do_not_change_own_widget(self): + from partner_programs.tests.test_solution_foundation import solution_program + from partner_programs.solution_api import create_solution + + other = solution_program(is_competitive=True) + owner = create_user() + create_program_member(other, user=owner) + solution, _ = create_solution(other.pk, owner, {}) + foreign = create_rate_expert(program=other) + self.expert.expert.programs.add(other) + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=other, solution=solution, expert=self.expert.expert + ), + SolutionExpertAssignment( + partner_program=self.program, + solution=self.solution, + expert=foreign.expert, + ), + ] + ) + self.assertEqual(self.widget(self.expert)["expert"]["assigned"], 1) + response = call(foreign, "get", self.widget_url + f"?user_id={self.expert.pk}") + self.assertEqual(response.status_code, 403) + self.assert_schema("WidgetError", response.data) + + def test_legacy_reference_is_not_recast_to_a_solution_when_program_mode_changed(self): + user = create_user() + create_program_member(self.program, user=user) + project = create_project(leader=user) + PartnerProgramProject.objects.bulk_create( + [PartnerProgramProject(partner_program=self.program, project=project)] + ) + response = call(user, "get", self.widget_url) + self.assertEqual(response.status_code, 409) + self.assert_schema("WidgetError", response.data) + self.assertNotIn("application", response.data) + + def test_legacy_widget_keeps_its_body_shape_and_matches_the_shared_contract(self): + legacy = create_partner_program(is_competitive=True) + create_program_member(legacy, user=self.captain) + project = create_project(leader=self.captain) + link = PartnerProgramProject.objects.create( + partner_program=legacy, project=project + ) + response = call(self.captain, "get", f"/programs/{legacy.pk}/analytics-widget/") + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RoleWidget", response.data) + self.assertEqual( + set(response.data), {"program_id", "role", "is_competitive", "participant"} + ) + self.assertEqual( + response.data["participant"]["participant_project"], + {"id": project.pk, "name": project.name, "program_link_id": link.pk}, + ) + + def test_query_count_is_constant_and_get_has_no_business_writes_for_each_role(self): + users = [self.manager, self.expert, self.captain] + counts = [] + for user in users: + with CaptureQueriesContext(connection) as queries: + self.widget(user) + counts.append(len(queries)) + self.assertLessEqual(len(queries), 14) + self.assertFalse( + any( + row["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE")) + for row in queries + ) + ) + for _ in range(8): + solution = self.another_solution() + SolutionExpertAssignment.objects.create( + partner_program=self.program, solution=solution, expert=self.expert.expert + ) + for user, expected in zip(users, counts): + with CaptureQueriesContext(connection) as queries: + self.widget(user) + self.assertEqual(len(queries), expected) diff --git a/partner_programs/tests/test_solution_submission.py b/partner_programs/tests/test_solution_submission.py new file mode 100644 index 00000000..8706ff61 --- /dev/null +++ b/partner_programs/tests/test_solution_submission.py @@ -0,0 +1,505 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier +from unittest.mock import patch + +from django.db import close_old_connections, transaction +from django.core.exceptions import ValidationError +from django.db.models.deletion import ProtectedError +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone + +from partner_programs import ( + solution_answers, + solution_invitations, + solution_forms, + solution_team, +) +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ( + ProgramSolutionMember, + ProgramSolutionAnswer, + SolutionInvitation, +) +from partner_programs.solution_submission import submit_solution +from partner_programs.tests.test_solution_answers import AnswerFixture +from partner_programs.tests.test_solution_forms import call, new_field +from partner_programs.tests.helpers import create_user, create_program_member + + +class SubmissionFixture(AnswerFixture): + def ready(self): + self.seed_answers() + self.program.legacy_team_min_size = 1 + self.program.legacy_team_max_size = 4 + self.program.save() + rows = [ + {"field_id": self.fields["select"]["id"], "value": "Задача 1"}, + {"field_id": self.fields["textarea"]["id"], "value": "Готовое решение"}, + {"field_id": self.fields["number"]["id"], "value": 0}, + {"field_id": self.fields["checkbox"]["id"], "value": False}, + ] + response = self.save_answers(rows) + assert response.status_code == 200, response.data + self.submit_url = self.url + "submit/" + + def submit(self, **changes): + return call(self.captain, "post", self.submit_url, self.command(**changes)) + + +class SolutionSubmissionTests(SubmissionFixture, TestCase): + def setUp(self): + self.ready() + + def assert_draft(self): + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "draft") + self.assertIsNone(self.solution.submitted_at) + self.assertIsNone(self.solution.submission_snapshot) + + def test_submit_matches_contract_and_snapshots_exact_values_and_accepted_team(self): + recipient = create_user() + SolutionInvitation.objects.create( + solution=self.solution, recipient=recipient, role="Дизайнер" + ) + before = self.command()["expected_version"] + response = self.submit() + self.assertEqual(response.status_code, 200, response.data) + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + from jsonschema import Draft202012Validator, FormatChecker + + Draft202012Validator( + {"components": spec["components"], "$ref": "#/components/schemas/Solution"}, + format_checker=FormatChecker(), + ).validate(response.data) + self.assertEqual(response.data["version"], before + 1) + self.assertEqual(response.data["submission"]["status"], "submitted") + self.assertTrue(response.data["form"]["is_snapshot"]) + self.assertTrue(response.data["readiness"]["is_ready"]) + self.assertEqual(response.data["team"]["accepted_count"], 1) + self.assertEqual(response.data["team"]["pending_count"], 1) + self.assertFalse(response.data["permissions"]["can_edit"]) + self.assertFalse(response.data["permissions"]["can_submit"]) + self.solution.refresh_from_db() + self.assertEqual( + self.solution.submission_snapshot["answers"], response.data["answers"] + ) + self.assertEqual( + self.solution.submission_snapshot["members"], response.data["team"]["members"] + ) + self.assertEqual( + self.solution.submission_snapshot["submitted_at"], + response.data["submission"]["submitted_at"], + ) + self.assertIn(False, [row["value"] for row in response.data["answers"]]) + self.assertIn(0, [row["value"] for row in response.data["answers"]]) + + def test_incomplete_case_and_invalid_choice_are_controlled_and_atomic(self): + self.save_answers([{"field_id": self.fields["select"]["id"], "value": None}]) + response = self.submit() + self.assert_error(response, "case_required", 400) + self.assertEqual( + response.data["errors"][0]["field_id"], self.fields["select"]["id"] + ) + self.assert_draft() + ProgramSolutionAnswer.objects.create( + solution=self.solution, + field_id=self.fields["select"]["id"], + value="Устаревший кейс", + ) + self.assert_error(self.submit(), "case_unavailable", 400) + self.assert_draft() + + def test_required_and_constraints_are_rechecked_even_for_old_data(self): + self.save_answers([{"field_id": self.fields["textarea"]["id"], "value": None}]) + response = self.submit() + self.assert_error(response, "required_program_fields", 400) + self.assert_draft() + ProgramSolutionAnswer.objects.create( + solution=self.solution, field_id=self.fields["textarea"]["id"], value=True + ) + self.assert_error(self.submit(), "invalid_answers", 400) + self.assert_draft() + + def test_only_captain_can_submit_visible_solution(self): + member = create_user() + create_program_member(self.program, user=member) + ProgramSolutionMember.objects.create(solution=self.solution, user=member) + for actor in (member, self.manager): + self.assert_error( + call(actor, "post", self.submit_url, self.command()), + "not_solution_captain", + 403, + ) + self.assert_error( + call(create_user(), "post", self.submit_url, self.command()), "not_found", 404 + ) + self.assert_draft() + + def test_minimum_counts_accepted_only_and_deadline_precedes_team_and_fields(self): + self.program.legacy_team_min_size = 2 + self.program.save() + pending = create_user() + SolutionInvitation.objects.create(solution=self.solution, recipient=pending) + self.assert_error(self.submit(), "team_min_size") + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save() + self.assert_error(self.submit(), "submission_closed") + self.assert_draft() + self.assertFalse( + call(self.captain, "get", self.url).data["permissions"]["can_submit"] + ) + + def test_inclusive_deadline_uses_existing_program_clock(self): + stamp = timezone.now() + self.program.datetime_project_submission_ends = stamp + self.program.save() + with patch("partner_programs.models.timezone.now", return_value=stamp): + response = self.submit() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + response.data["submission"]["submitted_at"], + stamp.isoformat().replace("+00:00", "Z"), + ) + + def test_versions_mode_and_noncompetitive_block_without_mutating_saved_draft(self): + body = self.command() + body["expected_version"] = 999 + self.assert_error( + call(self.captain, "post", self.submit_url, body), "version_conflict" + ) + body = self.command() + body["expected_form_revision"] = "b" * 64 + self.assert_error( + call(self.captain, "post", self.submit_url, body), "form_revision_conflict" + ) + self.program.is_competitive = False + self.program.save() + self.assert_error(self.submit(), "not_competitive") + data = call(self.captain, "get", self.url).data + self.assertIsNone(data["readiness"]) + self.assertFalse(data["submission"]["required"]) + self.assertEqual(self.save_answers([], name="Работа без сдачи").status_code, 200) + self.program.solution_storage_mode = "legacy_project" + self.program.save() + self.assert_error(self.submit(), "solution_mode_unavailable") + self.assert_draft() + + def test_repeated_submit_checks_final_state_before_stale_versions_and_new_deadline( + self, + ): + body = self.command() + self.assertEqual(self.submit().status_code, 200) + self.solution.refresh_from_db() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + version = self.solution.version + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save() + self.assert_error( + call(self.captain, "post", self.submit_url, body), "already_submitted" + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, version) + self.assertEqual(self.solution.submission_snapshot, snapshot) + + def test_snapshot_survives_form_labels_order_and_new_required_field(self): + submitted = self.submit().data + payload = self.payload() + payload["fields"].reverse() + for position, field in enumerate(payload["fields"]): + field["position"] = position + field["label"] = "Новое " + field["label"] + payload["fields"].append(new_field(position=20, required=True)) + solution_forms.save_form( + program_id=self.program.pk, actor=self.manager, **payload + ) + self.program.legacy_team_min_size = 3 + self.program.save() + current = call(self.captain, "get", self.url).data + self.assertEqual(current["form"], submitted["form"]) + self.assertEqual(current["answers"], submitted["answers"]) + self.assertTrue(current["readiness"]["is_ready"]) + + def test_file_disappearing_or_changing_before_submit_is_rejected(self): + upload = self.upload() + self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}] + ) + upload.detected_mime_type = "application/zip" + upload.save() + response = self.submit() + self.assert_error(response, "invalid_answers", 400) + self.assertEqual(response.data["errors"][0]["code"], "file_type_mismatch") + self.assert_draft() + upload.delete() + self.assert_error(self.submit(), "invalid_answers", 400) + self.assert_draft() + + def test_optional_file_with_invalid_legacy_shape_is_controlled(self): + ProgramSolutionAnswer.objects.create( + solution=self.solution, field_id=self.fields["file"]["id"], value="" + ) + self.assert_error(self.submit(), "invalid_answers", 400) + self.assert_draft() + + def test_submit_failure_rolls_back_transition_but_keeps_previous_patch(self): + with patch( + "partner_programs.solution_submission.advance_version", + side_effect=RuntimeError("commit failure"), + ): + with self.assertRaises(RuntimeError): + self.submit() + self.assert_draft() + self.assertEqual(self.solution.answers.count(), 4) + + def test_submitted_rejects_answer_and_team_commands_but_pending_revoke_works(self): + recipient = create_user() + create_program_member(self.program, user=recipient) + invitation = solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + expected_solution_version=self.command()["expected_version"], + recipient_user_id=recipient.pk, + role="Разработчик", + ) + self.assertEqual(self.submit().status_code, 200) + self.solution.refresh_from_db() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + self.assert_error(self.save_answers([], name="После сдачи"), "team_frozen") + with self.assertRaises(SolutionError) as error: + solution_invitations.decide_invitation( + invitation_id=invitation.pk, + actor=recipient, + expected_version=invitation.version, + accept=True, + ) + self.assertEqual(error.exception.payload["code"], "team_frozen") + solution_invitations.revoke_invitation( + invitation_id=invitation.pk, + actor=self.captain, + expected_version=invitation.version, + expected_solution_version=self.solution.version, + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + + def test_supported_model_writes_and_bulk_deletion_cannot_change_frozen_work(self): + self.assertEqual(self.submit().status_code, 200) + self.solution.refresh_from_db() + snapshot = copy.deepcopy(self.solution.submission_snapshot) + self.solution.name = "Подмена" + with self.assertRaises(ValidationError), transaction.atomic(): + self.solution.save() + self.solution.refresh_from_db() + self.solution.status = "draft" + self.solution.submitted_at = None + self.solution.submission_snapshot = None + with self.assertRaises(ValidationError), transaction.atomic(): + self.solution.save() + answer = self.solution.answers.first() + answer.value = "Подмена" + with self.assertRaises(ValidationError), transaction.atomic(): + answer.save() + with self.assertRaises(ValidationError), transaction.atomic(): + ProgramSolutionMember.objects.create( + solution=self.solution, user=create_user() + ) + with self.assertRaises(ValidationError), transaction.atomic(): + SolutionInvitation.objects.create( + solution=self.solution, recipient=create_user() + ) + with self.assertRaises(ProtectedError), transaction.atomic(): + self.solution.members.all().delete() + with self.assertRaises(ProtectedError), transaction.atomic(): + self.solution.answers.all().delete() + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + self.assertEqual(self.solution.status, "submitted") + + +class SolutionSubmissionConcurrencyTests(SubmissionFixture, TransactionTestCase): + def race(self, actions): + barrier = Barrier(2) + + def run(action): + close_old_connections() + try: + barrier.wait(timeout=10) + action() + return "saved" + except SolutionError as error: + return error.payload["code"] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + return list(pool.map(run, actions)) + + def submit_action(self): + command = self.command() + return lambda: submit_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **command + ) + + @skipUnlessDBFeature("has_select_for_update") + def test_two_submits_make_exactly_one_snapshot(self): + self.ready() + action = self.submit_action() + self.assertCountEqual(self.race([action, action]), ["saved", "already_submitted"]) + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "submitted") + + @skipUnlessDBFeature("has_select_for_update") + def test_submit_and_patch_serialize_without_changing_snapshot(self): + self.ready() + submit = self.submit_action() + command = self.command(name="Одновременная правка") + + def update(): + solution_answers.update_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + **command + ) + + results = self.race([submit, update]) + self.solution.refresh_from_db() + if self.solution.status == "submitted": + self.assertEqual(results, ["saved", "team_frozen"]) + self.assertNotEqual( + self.solution.submission_snapshot["name"], "Одновременная правка" + ) + else: + self.assertEqual(results, ["version_conflict", "saved"]) + self.assertEqual(self.solution.name, "Одновременная правка") + self.assertIsNone(self.solution.submission_snapshot) + + @skipUnlessDBFeature("has_select_for_update") + def test_submit_and_remove_keep_one_accepted_roster(self): + self.ready() + member = create_user() + create_program_member(self.program, user=member) + ProgramSolutionMember.objects.create(solution=self.solution, user=member) + submit = self.submit_action() + version = self.solution.version + + def remove(): + solution_team.remove_member( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + user_id=member.pk, + expected_version=version, + ) + + results = self.race([submit, remove]) + self.solution.refresh_from_db() + if self.solution.status == "submitted": + self.assertEqual(results, ["saved", "team_frozen"]) + self.assertEqual(len(self.solution.submission_snapshot["members"]), 2) + else: + self.assertEqual(results, ["version_conflict", "saved"]) + self.assertEqual(self.solution.members.count(), 1) + + @skipUnlessDBFeature("has_select_for_update") + def test_submit_and_accept_never_add_a_member_to_the_frozen_roster(self): + self.ready() + recipient = create_user() + create_program_member(self.program, user=recipient) + invitation = solution_invitations.create_invitation( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.captain, + expected_solution_version=self.command()["expected_version"], + recipient_user_id=recipient.pk, + role="Аналитик", + ) + submit = self.submit_action() + + def accept(): + solution_invitations.decide_invitation( + invitation_id=invitation.pk, + actor=recipient, + expected_version=invitation.version, + accept=True, + ) + + results = self.race([submit, accept]) + self.solution.refresh_from_db() + if self.solution.status == "submitted": + self.assertEqual(results, ["saved", "team_frozen"]) + self.assertEqual(len(self.solution.submission_snapshot["members"]), 1) + self.assertEqual(self.solution.members.count(), 1) + else: + self.assertEqual(results, ["version_conflict", "saved"]) + self.assertEqual(self.solution.members.count(), 2) + + @skipUnlessDBFeature("has_select_for_update") + def test_submit_and_form_change_either_conflict_or_keep_the_old_snapshot(self): + self.ready() + submit = self.submit_action() + payload = self.payload() + old_revision = payload["expected_revision"] + payload["fields"].append(new_field(position=20, required=True)) + + def configure(): + solution_forms.save_form( + program_id=self.program.pk, actor=self.manager, **payload + ) + + results = self.race([submit, configure]) + self.solution.refresh_from_db() + if self.solution.status == "submitted": + self.assertEqual(results, ["saved", "saved"]) + self.assertEqual( + self.solution.submission_snapshot["form"]["revision"], old_revision + ) + else: + self.assertEqual(results, ["form_revision_conflict", "saved"]) + self.assertIsNone(self.solution.submission_snapshot) + + @skipUnlessDBFeature("has_select_for_update") + def test_file_delete_and_submit_share_the_file_lock(self): + from files.views import FileView + from files.models import UserFile + from rest_framework.test import APIClient + + self.ready() + upload = self.upload() + self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}] + ) + submit = self.submit_action() + + def delete(): + client = APIClient() + client.force_authenticate(self.captain) + response = client.delete("/files/", QUERY_STRING="link=" + upload.link) + if response.status_code != 204: + raise SolutionError(response.data["code"], response.data["detail"]) + + with patch.object(FileView.cdn, "delete"): + results = self.race([submit, delete]) + self.solution.refresh_from_db() + if self.solution.status == "submitted": + self.assertEqual(results, ["saved", "file_in_use"]) + self.assertTrue(UserFile.objects.filter(pk=upload.pk).exists()) + else: + self.assertEqual(results, ["invalid_answers", "saved"]) + self.assertFalse(UserFile.objects.filter(pk=upload.pk).exists()) diff --git a/partner_programs/tests/test_solution_submission_actions.py b/partner_programs/tests/test_solution_submission_actions.py new file mode 100644 index 00000000..32ba92bf --- /dev/null +++ b/partner_programs/tests/test_solution_submission_actions.py @@ -0,0 +1,491 @@ +import copy +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from queue import Queue +from threading import Barrier +from time import monotonic, sleep + +from django.contrib.auth import get_user_model +from django.core import signing +from django.db import close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase, override_settings +from django.utils import timezone +from jsonschema import Draft202012Validator + +from files.models import UserFile +from partner_programs.models import PartnerProgram, PartnerProgramField +from partner_programs.services import solution_submission_actions as actions +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionAnswer, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs.tests.helpers import ( + create_user, + create_program_member, + create_partner_program, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from projects.models import Project +from project_rates.tests.helpers import create_rate_expert + +FAST_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + + +class ActionFixture(SubmissionFixture): + def setUp(self): + self.ready() + self.action_root = f"/programs/{self.program.pk}/management/solutions/actions/" + + def selection(self, ids=None, **extra): + return dict(action="submit", ids=ids or [self.solution.pk], **extra) + + def preview(self, ids=None, actor=None): + return call( + actor or self.manager, + "post", + self.action_root + "preview/", + self.selection(ids), + ) + + def confirmation(self, ids=None): + response = self.preview(ids) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["can_apply"], response.data) + return self.selection(ids, preview_token=response.data["preview_token"]) + + def apply(self, data, actor=None): + return call(actor or self.manager, "post", self.action_root + "apply/", data) + + def wire(self, response, status=200, schema="SolutionActionPreview"): + self.assertEqual(response.status_code, status, response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + { + "components": spec["components"], + "$ref": "#/components/schemas/" + (schema if status == 200 else "Error"), + } + ).validate(response.data) + return response.data + + def saved(self, solution=None): + solution = solution or self.solution + solution.refresh_from_db() + return dict( + version=solution.version, + status=solution.status, + submitted_at=solution.submitted_at, + snapshot=copy.deepcopy(solution.submission_snapshot), + ) + + def another(self, *, incomplete=False): + captain = create_user() + create_program_member(self.program, user=captain) + solution = ProgramSolution.objects.create( + program=self.program, captain=captain, name="Второе решение" + ) + ProgramSolutionMember.objects.create(solution=solution, user=captain) + ProgramSolutionAnswer.objects.bulk_create( + [ + ProgramSolutionAnswer( + solution=solution, + field_id=row.field_id, + value=copy.deepcopy(row.value), + ) + for row in self.solution.answers.all() + if not (incomplete and row.field_id == self.fields["textarea"]["id"]) + ] + ) + return solution + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionSubmissionActionTests(ActionFixture, TestCase): + def test_preview_is_read_only_uses_solution_refs_and_normalizes_duplicate_ids(self): + before = self.saved() + result = self.wire(self.preview([self.solution.pk, self.solution.pk])) + self.assertEqual(result["ready_count"], 1) + self.assertEqual(result["already_submitted_count"], 0) + self.assertEqual( + result["items"][0]["application"]["solution_id"], self.solution.pk + ) + self.assertEqual(result["items"][0]["application"]["kind"], "solution") + proof = signing.loads(result["preview_token"], salt=actions.SALT) + self.assertEqual(proof["ids"], [self.solution.pk]) + self.assertEqual(set(proof), {"actor", "program", "action", "ids", "state"}) + self.assertEqual(self.saved(), before) + + def test_late_first_submission_keeps_captain_deadline_and_exact_snapshot(self): + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(days=1) + ) + self.program.save() + self.assert_error(self.submit(), "submission_closed") + pending = create_user() + SolutionInvitation.objects.create( + solution=self.solution, recipient=pending, role="Дизайнер" + ) + before_projects = Project.objects.count() + before = self.saved() + self.wire(self.apply(self.confirmation()), schema="SolutionActionResult") + after = self.saved() + self.assertEqual(after["version"], before["version"] + 1) + self.assertEqual(after["status"], "submitted") + snapshot = after["snapshot"] + values = {a["field_id"]: a["value"] for a in snapshot["answers"]} + self.assertEqual(values[self.fields["number"]["id"]], 0) + self.assertIs(values[self.fields["checkbox"]["id"]], False) + self.assertEqual( + [m["user"]["id"] for m in snapshot["members"]], [self.captain.pk] + ) + self.assertTrue(snapshot["form"]["is_snapshot"]) + self.assertEqual(Project.objects.count(), before_projects) + self.assertEqual(self.solution.invitations.filter(status="pending").count(), 1) + self.assert_error(self.save_answers(name="Поздняя правка"), "team_frozen") + self.assertEqual(self.saved(), after) + + def test_all_already_submitted_cannot_apply_and_do_not_change_saved_state(self): + self.assertEqual(self.submit().status_code, 200) + before = self.saved() + result = self.wire(self.preview()) + self.assertEqual( + (result["ready_count"], result["already_submitted_count"]), (0, 1) + ) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertEqual(self.saved(), before) + + def test_mixed_bulk_skips_submitted_and_preserves_its_date_and_snapshot(self): + other = self.another() + self.assertEqual(self.submit().status_code, 200) + before = self.saved() + result = self.wire( + self.apply(self.confirmation([other.pk, self.solution.pk])), + schema="SolutionActionResult", + ) + self.assertEqual(result, {"changed_count": 1, "already_submitted_count": 1}) + self.assertEqual(self.saved(), before) + self.assertEqual(self.saved(other)["status"], "submitted") + + def test_incomplete_bulk_never_offers_token_or_changes_ready_neighbor(self): + other = self.another(incomplete=True) + before = self.saved() + result = self.wire(self.preview([other.pk, self.solution.pk])) + self.assertEqual(result["ready_count"], 1) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertTrue( + next( + i for i in result["items"] if i["application"]["solution_id"] == other.pk + )["errors"] + ) + self.assertEqual(self.saved(), before) + self.assertEqual(self.saved(other)["status"], "draft") + + def test_roles_foreign_manager_and_anonymous_are_denied_on_both_operations(self): + member = create_user() + create_program_member(self.program, user=member) + ProgramSolutionMember.objects.create(solution=self.solution, user=member) + outsider = create_user() + foreign_program = create_partner_program() + foreign_program.managers.add(outsider) + expert = create_rate_expert(program=self.program) + for actor in [self.captain, member, outsider, expert, None]: + for operation in ["preview", "apply"]: + with self.subTest(actor=actor and actor.pk, operation=operation): + data = self.selection( + **({"preview_token": "invalid"} if operation == "apply" else {}) + ) + self.wire( + call(actor, "post", self.action_root + operation + "/", data), + 401 if actor is None else 403, + ) + + def test_staff_uses_existing_program_admin_right(self): + staff = create_user(is_staff=True) + self.wire(self.preview(actor=staff)) + + def test_legacy_and_noncompetitive_modes_do_not_write(self): + before = self.saved() + for changes, code in [ + ({"solution_storage_mode": "legacy_project"}, "solution_mode_unavailable"), + ( + {"solution_storage_mode": "solution", "is_competitive": False}, + "not_competitive", + ), + ]: + PartnerProgram.objects.filter(pk=self.program.pk).update(**changes) + for operation in ["preview", "apply"]: + data = self.selection( + **({"preview_token": "invalid"} if operation == "apply" else {}) + ) + response = call( + self.manager, "post", self.action_root + operation + "/", data + ) + self.assertEqual(self.wire(response, 409)["code"], code) + self.assertEqual(self.saved(), before) + + def test_foreign_and_missing_solution_ids_do_not_disclose_or_submit(self): + foreign = ProgramSolution.objects.create( + program=create_partner_program(), captain=create_user() + ) + for ids in [[foreign.pk], [self.solution.pk, foreign.pk], [9007199254740990]]: + self.wire(self.preview(ids), 404) + self.assertEqual(self.saved()["status"], "draft") + + def test_strict_body_query_safe_ids_and_only_submit_action(self): + invalid = [ + dict(action="submit", ids=[]), + dict(action="submit", ids=[True]), + dict(action="submit", ids=[0]), + dict(action="submit", ids=[2**53]), + dict(action="submit", ids=[self.solution.pk] * 10001), + dict(action="detach", ids=[self.solution.pk]), + self.selection(force=True), + [], + None, + ] + for data in invalid: + with self.subTest(data=str(data)[:100]): + self.wire( + call(self.manager, "post", self.action_root + "preview/", data), 400 + ) + self.wire( + call( + self.manager, + "post", + self.action_root + "preview/?force=true", + self.selection(), + ), + 400, + ) + + def test_token_signature_actor_expiry_shape_and_replay(self): + confirmation = self.confirmation() + proof = signing.loads(confirmation["preview_token"], salt=actions.SALT) + other_manager = create_user() + self.program.managers.add(other_manager) + response = self.apply(confirmation, actor=other_manager) + self.assertEqual(self.wire(response, 409)["code"], "solution_action_conflict") + from unittest.mock import patch + + for token in [ + "invalid", + signing.dumps([], salt=actions.SALT), + signing.dumps({**proof, "program": self.program.pk + 1}, salt=actions.SALT), + signing.dumps({**proof, "ids": []}, salt=actions.SALT), + signing.dumps({**proof, "action": "detach"}, salt=actions.SALT), + ]: + self.assertEqual( + self.wire(self.apply({**confirmation, "preview_token": token}), 409)[ + "code" + ], + "solution_action_conflict", + ) + with patch( + "django.core.signing.time.time", return_value=timezone.now().timestamp() + 601 + ): + self.wire(self.apply(confirmation), 409) + self.wire(self.apply(confirmation), schema="SolutionActionResult") + after = self.saved() + self.wire(self.apply(confirmation), 409) + self.assertEqual(self.saved(), after) + + def test_saved_state_changes_invalidate_preview_even_without_solution_version_bump( + self, + ): + upload = self.upload() + self.assertEqual( + self.save_answers( + [{"field_id": self.fields["file"]["id"], "value": {"url": upload.link}}] + ).status_code, + 200, + ) + changes = [ + lambda: ProgramSolution.objects.filter(pk=self.solution.pk).update( + name="Новое имя" + ), + lambda: self.solution.answers.filter( + field_id=self.fields["textarea"]["id"] + ).update(value="Новый ответ"), + lambda: self.solution.members.update(role="Новая роль"), + lambda: PartnerProgramField.objects.filter( + pk=self.fields["textarea"]["id"] + ).update(label="Другая подпись"), + lambda: PartnerProgram.objects.filter(pk=self.program.pk).update( + legacy_team_max_size=5 + ), + lambda: UserFile.objects.filter(pk=upload.pk).update(size=101), + lambda: get_user_model() + .objects.filter(pk=self.captain.pk) + .update(first_name="Новое имя"), + ] + for index, change in enumerate(changes): + with self.subTest(index=index), transaction.atomic(): + confirmation = self.confirmation() + change() + before = self.saved() + self.assertEqual( + self.wire(self.apply(confirmation), 409)["code"], + "solution_action_conflict", + ) + self.assertEqual(self.saved(), before) + transaction.set_rollback(True) + + def test_required_case_team_and_registration_still_block_preview(self): + changes = [ + lambda: self.solution.answers.filter( + field_id=self.fields["select"]["id"] + ).delete(), + lambda: self.solution.answers.filter( + field_id=self.fields["select"]["id"] + ).update(value="Несуществующий кейс"), + lambda: PartnerProgram.objects.filter(pk=self.program.pk).update( + legacy_team_min_size=2 + ), + lambda: self.solution.answers.filter( + field_id=self.fields["textarea"]["id"] + ).delete(), + lambda: ProgramSolutionMember.objects.create( + solution=self.solution, user=create_user() + ), + ] + for index, change in enumerate(changes): + with self.subTest(index=index), transaction.atomic(): + change() + result = self.wire(self.preview()) + self.assertFalse(result["can_apply"]) + self.assertIsNone(result["preview_token"]) + self.assertTrue(result["items"][0]["errors"]) + self.assertEqual(self.saved()["status"], "draft") + transaction.set_rollback(True) + + def test_unverified_file_failure_rolls_back_entire_bulk_after_first_finalization( + self, + ): + other = self.another() + upload = self.upload(user=other.captain, detected="") + ProgramSolutionAnswer.objects.create( + solution=other, field_id=self.fields["file"]["id"], value={"url": upload.link} + ) + confirmation = self.confirmation([self.solution.pk, other.pk]) + before = [self.saved(), self.saved(other)] + response = self.apply(confirmation) + self.assertEqual(self.wire(response, 400)["code"], "invalid_answers") + self.assertEqual([self.saved(), self.saved(other)], before) + upload.refresh_from_db() + self.assertEqual(upload.detected_mime_type, "") + + def test_creation_disabled_does_not_block_existing_ready_solution(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + solution_creation_enabled=False + ) + result = self.wire(self.apply(self.confirmation()), schema="SolutionActionResult") + self.assertEqual(result["changed_count"], 1) + + +@override_settings(PASSWORD_HASHERS=FAST_HASHERS) +class SolutionSubmissionActionConcurrencyTests(ActionFixture, TransactionTestCase): + def setUp(self): + if connection.vendor != "postgresql": + self.skipTest("Requires PostgreSQL program row locks") + super().setUp() + + def run_race(self, commands): + barrier, pids = Barrier(2), Queue() + + def worker(command): + close_old_connections() + try: + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + pids.put(cursor.fetchone()[0]) + barrier.wait(timeout=10) + try: + return command() + except SolutionError as error: + return error.payload["code"] + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + with transaction.atomic(): + PartnerProgram.objects.select_for_update().get(pk=self.program.pk) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_backend_pid()") + controller = cursor.fetchone()[0] + futures = [pool.submit(worker, command) for command in commands] + workers = [pids.get(timeout=10), pids.get(timeout=10)] + until, paths = monotonic() + 10, [] + while monotonic() < until: + with connection.cursor() as cursor: + cursor.execute( + "SELECT pid, pg_blocking_pids(pid) FROM pg_stat_activity WHERE datname=current_database()" + ) + blockers = dict(cursor.fetchall()) + paths = [] + for pid in workers: + seen, queue = set(), [pid] + while queue: + current = queue.pop() + if current in seen: + continue + seen.add(current) + queue.extend(blockers.get(current, [])) + paths.append(controller in seen) + if all(paths): + break + sleep(0.01) + self.assertEqual(paths, [True, True]) + results = [future.result(timeout=20) for future in futures] + return results + + def test_two_confirmations_wait_for_program_and_only_one_commits(self): + data = self.confirmation() + before = self.saved() + results = self.run_race( + [lambda: actions.apply(self.program.pk, self.manager, data)] * 2 + ) + self.assertEqual(results.count("solution_action_conflict"), 1) + self.assertIn({"changed_count": 1, "already_submitted_count": 0}, results) + after = self.saved() + self.assertEqual(after["version"], before["version"] + 1) + self.assertEqual(after["status"], "submitted") + + def test_confirmation_and_captain_patch_have_only_serialized_outcomes(self): + data = self.confirmation() + before = self.saved() + name = self.solution.name + + def patch_materials(): + response = self.save_answers(name="Изменено после проверки") + if response.status_code == 409: + return response.data["code"] + self.assertEqual(response.status_code, 200, response.data) + return "patched" + + results = self.run_race( + [lambda: actions.apply(self.program.pk, self.manager, data), patch_materials] + ) + after = self.saved() + self.assertEqual(after["version"], before["version"] + 1) + if "patched" in results: + self.assertIn("solution_action_conflict", results) + self.assertEqual(after["status"], "draft") + self.assertIsNone(after["snapshot"]) + self.assertEqual(self.solution.name, "Изменено после проверки") + else: + self.assertIn("team_frozen", results) + self.assertIn({"changed_count": 1, "already_submitted_count": 0}, results) + self.assertEqual(after["status"], "submitted") + self.assertEqual(after["snapshot"]["name"], name) diff --git a/partner_programs/tests/test_solution_team.py b/partner_programs/tests/test_solution_team.py new file mode 100644 index 00000000..ace3618c --- /dev/null +++ b/partner_programs/tests/test_solution_team.py @@ -0,0 +1,505 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +from django.core.exceptions import ValidationError +from django.db import close_old_connections, transaction +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.solution_api import create_solution +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + SolutionInvitation, +) +from partner_programs import solution_team +from partner_programs.tests.helpers import ( + create_program_member, + create_project, + create_user, +) +from partner_programs.tests.test_solution_foundation import solution_program +from projects import team_policy, team_service +from projects.models import Collaborator + + +class TeamFixture: + def legacy_team(self): + # Историческая заявка создана до переключения программы в новый режим. + self.program.solution_storage_mode = "legacy_project" + self.program.save(update_fields=["solution_storage_mode"]) + project = create_project(leader=self.registered()) + PartnerProgramProject.objects.create( + partner_program=self.program, project=project + ) + self.program.solution_storage_mode = "solution" + self.program.save(update_fields=["solution_storage_mode"]) + return project + + def registered(self, program=None): + user = create_user() + create_program_member(program or self.program, user=user) + return user + + def team(self, program=None): + program = program or self.program + captain = self.registered(program) + obj, _ = create_solution(program.pk, captain, {}) + return obj + + def member(self, solution=None): + solution = solution or self.solution + user = self.registered(solution.program) + ProgramSolutionMember.objects.create( + solution=solution, user=user, role="Разработчик" + ) + return user + + def request(self, actor, method, suffix, data=None, *, program=None, solution=None): + program = program or self.program + solution = solution or self.solution + client = APIClient() + client.force_authenticate(actor) + return getattr(client, method)( + f"/programs/{program.pk}/solutions/{solution.pk}/{suffix}", + {} if data is None else data, + format="json", + ) + + def assert_error(self, response, status, code): + self.assertEqual(response.status_code, status, response.data) + self.assertEqual(response.data["code"], code) + self.assertEqual(set(response.data), {"code", "detail", "errors", "meta"}) + + +class SolutionTeamTests(TeamFixture, TestCase): + def setUp(self): + self.program = solution_program( + is_competitive=True, legacy_team_min_size=3, legacy_team_max_size=4 + ) + self.solution = self.team() + self.captain = self.solution.captain + self.user = self.member() + + def remove(self, actor=None, user=None, version=1): + user = user or self.user + return self.request( + actor or self.captain, + "delete", + f"members/{user.pk}/?expected_solution_version={version}", + ) + + def transfer(self, actor=None, user=None, version=1): + user = user or self.user + return self.request( + actor or self.captain, + "post", + "captain/", + {"new_captain_user_id": user.pk, "expected_solution_version": version}, + ) + + def freeze(self): + ProgramSolution.objects.filter(pk=self.solution.pk).update( + status="submitted", submitted_at=timezone.now(), submission_snapshot={} + ) + + def test_remove_returns_c00_receipt_bumps_version_and_ignores_draft_minimum(self): + response = self.remove() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["solution_id"], self.solution.pk) + self.assertEqual(response.data["solution_version"], 2) + self.assertFalse(self.solution.members.filter(user=self.user).exists()) + self.assertEqual(self.solution.members.get().user_id, self.captain.pk) + from jsonschema import Draft202012Validator + + contract = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf8") + ) + Draft202012Validator( + { + "$ref": "#/components/schemas/MutationReceipt", + "components": contract["components"], + } + ).validate(response.data) + + def test_removed_user_loses_read_and_current_application(self): + self.remove() + self.assert_error(self.request(self.user, "get", ""), 404, "not_found") + client = APIClient() + client.force_authenticate(self.user) + self.assertIsNone( + client.get(f"/programs/{self.program.pk}/solution-context/").data[ + "current_application" + ] + ) + + def test_member_can_leave_without_read_response_and_cannot_remove_someone_else(self): + self.assert_error(self.remove(actor=self.user), 403, "not_solution_captain") + response = self.request( + self.user, "post", "leave/", {"expected_solution_version": 1} + ) + self.assertEqual(response.status_code, 204, response.data) + self.assertEqual(response.content, b"") + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 2) + self.assert_error(self.request(self.user, "get", ""), 404, "not_found") + + def test_captain_cannot_be_removed_or_leave(self): + self.assert_error(self.remove(user=self.captain), 409, "captain_cannot_leave") + self.assert_error( + self.request( + self.captain, "post", "leave/", {"expected_solution_version": 1} + ), + 409, + "captain_cannot_leave", + ) + self.assertEqual(self.solution.members.count(), 2) + + def test_transfer_keeps_old_captain_and_changes_server_permissions(self): + response = self.transfer() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["solution_version"], 2) + self.solution.refresh_from_db() + self.assertEqual(self.solution.captain_id, self.user.pk) + self.assertEqual(self.solution.members.count(), 2) + for actor, can_manage in [(self.captain, False), (self.user, True)]: + detail = self.request(actor, "get", "").data + self.assertEqual(detail["permissions"]["can_manage_team"], can_manage) + self.assertEqual(detail["permissions"]["can_leave"], not can_manage) + self.assertEqual(sum(m["is_captain"] for m in detail["team"]["members"]), 1) + self.assertEqual(detail["team"]["accepted_count"], 2) + self.assert_error(self.remove(version=2), 403, "not_solution_captain") + self.assertEqual( + self.request( + self.captain, "post", "leave/", {"expected_solution_version": 2} + ).status_code, + 204, + ) + + def test_transfer_requires_registered_accepted_member(self): + self.assert_error( + self.transfer(user=self.captain), 409, "already_solution_captain" + ) + self.assert_error(self.transfer(user=self.registered()), 409, "member_not_found") + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, user=self.user + ).delete() + self.assert_error(self.transfer(), 409, "not_program_member") + self.solution.refresh_from_db() + self.assertEqual(self.solution.captain_id, self.captain.pk) + + def test_outsider_manager_staff_and_foreign_program_do_not_manage_team(self): + outsider = self.registered() + self.assert_error(self.transfer(actor=outsider), 404, "not_found") + manager = create_user(is_staff=True) + self.program.managers.add(manager) + self.assert_error(self.transfer(actor=manager), 403, "not_solution_captain") + self.assert_error(self.remove(actor=manager), 403, "not_solution_captain") + self.assert_error( + self.request(manager, "post", "leave/", {"expected_solution_version": 1}), + 403, + "not_solution_member", + ) + other = solution_program() + self.assert_error( + self.request( + self.captain, + "post", + "captain/", + {"new_captain_user_id": self.user.pk, "expected_solution_version": 1}, + program=other, + ), + 404, + "not_found", + ) + + def test_stale_version_never_changes_team_and_returns_current_version(self): + result = self.remove(version=2) + self.assert_error(result, 409, "version_conflict") + self.assertEqual(result.data["meta"]["current_version"], 1) + self.assert_error(self.transfer(version=2), 409, "version_conflict") + self.assert_error( + self.request(self.user, "post", "leave/", {"expected_solution_version": 2}), + 409, + "version_conflict", + ) + self.assertEqual(self.solution.members.count(), 2) + + def test_submitted_team_rejects_all_commands_and_exposes_no_manage_permission(self): + self.freeze() + for response in [ + self.remove(), + self.transfer(), + self.request(self.user, "post", "leave/", {"expected_solution_version": 1}), + ]: + self.assert_error(response, 409, "team_frozen") + self.assertFalse( + self.request(self.captain, "get", "").data["permissions"]["can_manage_team"] + ) + self.assertFalse( + self.request(self.user, "get", "").data["permissions"]["can_leave"] + ) + self.assertEqual(self.solution.members.count(), 2) + + def test_disabled_mode_is_readable_but_cannot_mutate(self): + self.program.solution_storage_mode = "legacy_project" + self.program.save() + self.assert_error(self.remove(), 409, "solution_mode_unavailable") + self.assert_error(self.transfer(), 409, "solution_mode_unavailable") + self.assertEqual(self.request(self.captain, "get", "").status_code, 200) + + def test_strict_command_and_query_validation(self): + for value in [None, True, 1.0, "1", 0, -1]: + with self.subTest(value=value): + self.assert_error( + self.request( + self.user, "post", "leave/", {"expected_solution_version": value} + ), + 400, + "validation_error", + ) + for query in [ + "", + "?expected_solution_version=1.0", + "?expected_solution_version=1&user_id=1", + ]: + self.assert_error( + self.request(self.captain, "delete", f"members/{self.user.pk}/{query}"), + 400, + "validation_error", + ) + self.assert_error( + self.request( + self.user, + "post", + "leave/", + {"expected_solution_version": 1, "solution_id": self.solution.pk}, + ), + 400, + "validation_error", + ) + self.assert_error( + self.request( + self.captain, + "post", + "captain/", + {"expected_solution_version": 1, "new_captain_user_id": True}, + ), + 400, + "validation_error", + ) + + def test_missing_and_unsafe_identifiers_are_hidden(self): + self.assert_error( + self.request( + self.captain, + "delete", + "members/9007199254740992/?expected_solution_version=1", + ), + 404, + "not_found", + ) + self.assert_error( + self.request( + self.captain, "delete", "members/999999999/?expected_solution_version=1" + ), + 404, + "not_found", + ) + self.assertEqual(self.solution.members.count(), 2) + + def test_existing_deadline_does_not_add_a_new_team_restriction(self): + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(days=1) + ) + self.program.save() + self.assertEqual(self.remove().status_code, 200) + + def test_reservations_are_unique_and_minimum_uses_only_accepted_users(self): + pending = self.registered() + SolutionInvitation.objects.create(solution=self.solution, recipient=pending) + with transaction.atomic(), self.assertRaises(SolutionError) as error: + solution_team.validate_submission_team(self.solution) + self.assertEqual(str(error.exception.get_codes()), "team_min_size") + detail = self.request(self.captain, "get", "").data["team"] + self.assertEqual( + (detail["accepted_count"], detail["pending_count"], detail["reserved_count"]), + (2, 1, 3), + ) + with transaction.atomic(): + solution_team.validate_new_member(self.solution, pending.pk) + self.program.legacy_team_max_size = 3 + self.program.save() + self.solution.program = self.program + with transaction.atomic(), self.assertRaises(SolutionError) as error: + solution_team.validate_new_member(self.solution, self.registered().pk) + self.assertEqual(str(error.exception.get_codes()), "team_max_size") + + def test_exclusivity_checks_both_owners_and_legacy_model_admin_validation(self): + project = self.legacy_team() + with self.assertRaises(ValidationError) as error: + Collaborator.objects.create(project=project, user=self.user) + codes = [ + item.code for values in error.exception.error_dict.values() for item in values + ] + self.assertIn("already_in_program_team", codes) + self.assertFalse( + team_policy.actionable_users( + project, type(self.user).objects.filter(pk=self.user.pk) + ).exists() + ) + legacy_user = self.registered() + Collaborator.objects.create(project=project, user=legacy_user) + with transaction.atomic(), self.assertRaises(SolutionError) as error: + solution_team.validate_new_member(self.solution, legacy_user.pk) + self.assertEqual(str(error.exception.get_codes()), "already_in_program_team") + other = self.team() + with transaction.atomic(), self.assertRaises(SolutionError): + solution_team.validate_new_member(other, self.captain.pk) + + def test_noncompetitive_finished_and_different_programs_keep_existing_exceptions( + self, + ): + for changes in [ + {"is_competitive": False}, + {"datetime_finished": timezone.now() - timezone.timedelta(days=1)}, + ]: + for key, value in changes.items(): + setattr(self.program, key, value) + self.program.save() + other = self.team() + with transaction.atomic(): + solution_team.validate_new_member(other, self.user.pk) + self.program.is_competitive = True + other_program = solution_program(is_competitive=True) + create_program_member(other_program, user=self.user) + other = self.team(other_program) + with transaction.atomic(): + solution_team.validate_new_member(other, self.user.pk) + + def test_transfer_in_noncompetitive_program_cannot_violate_one_captain_application( + self, + ): + self.program.is_competitive = False + self.program.save() + other = self.team() + ProgramSolutionMember.objects.create(solution=self.solution, user=other.captain) + self.assert_error( + self.transfer(user=other.captain), 409, "already_solution_captain" + ) + self.solution.refresh_from_db() + self.assertEqual(self.solution.captain_id, self.captain.pk) + + def test_anonymous_cannot_use_any_team_command(self): + for method, suffix, data in [ + ("delete", f"members/{self.user.pk}/?expected_solution_version=1", {}), + ("post", "leave/", {"expected_solution_version": 1}), + ( + "post", + "captain/", + {"expected_solution_version": 1, "new_captain_user_id": self.user.pk}, + ), + ]: + self.assert_error( + self.request(None, method, suffix, data), 401, "authentication_required" + ) + + +@skipUnlessDBFeature("has_select_for_update") +class SolutionTeamConcurrencyTests(TeamFixture, TransactionTestCase): + def setUp(self): + self.program = solution_program(is_competitive=True) + self.solution = self.team() + self.user = self.member() + + def parallel(self, functions): + barrier = Barrier(len(functions)) + + def run(function): + close_old_connections() + try: + barrier.wait(timeout=15) + return function() + except SolutionError as error: + return str(error.get_codes()) + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=len(functions)) as pool: + return list(pool.map(run, functions)) + + def test_concurrent_captain_commands_only_one_version_succeeds(self): + another = self.member() + + def transfer(user): + return solution_team.transfer_captain( + program_id=self.program.pk, + solution_id=self.solution.pk, + user_id=user.pk, + actor=self.solution.captain, + expected_version=1, + ).version + + results = self.parallel([lambda: transfer(self.user), lambda: transfer(another)]) + self.assertEqual(results.count(2), 1) + self.assertEqual(sum(isinstance(result, str) for result in results), 1) + self.solution.refresh_from_db() + self.assertEqual(self.solution.version, 2) + self.assertEqual(self.solution.members.count(), 3) + + def test_same_version_remove_and_leave_do_not_double_apply(self): + def remove(): + solution_team.remove_member( + program_id=self.program.pk, + solution_id=self.solution.pk, + user_id=self.user.pk, + actor=self.solution.captain, + expected_version=1, + ) + return "removed" + + results = self.parallel([remove, remove]) + self.assertCountEqual(results, ["removed", "version_conflict"]) + + def test_solution_and_legacy_admission_share_program_lock(self): + candidate = self.registered() + project = self.legacy_team() + + def solution_admission(): + # Будущий accept BE03 обязан использовать эту же policy под lock_solution. + with transaction.atomic(): + obj = solution_team.lock_solution( + self.program.pk, self.solution.pk, self.solution.captain + ) + solution_team.validate_new_member(obj, candidate.pk) + ProgramSolutionMember.objects.create(solution=obj, user=candidate) + return "solution" + + def legacy_admission(): + try: + team_service.add_member( + project_id=project.pk, user_id=candidate.pk, actor=project.leader + ) + return "legacy" + except Exception as error: + from projects.team_errors import TeamError + + if isinstance(error, TeamError): + return str(error.detail["code"]) + raise + + results = self.parallel([solution_admission, legacy_admission]) + self.assertEqual(results.count("already_in_program_team"), 1) + self.assertEqual( + ProgramSolutionMember.objects.filter(user=candidate).count() + + Collaborator.objects.filter(user=candidate).count(), + 1, + ) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 8fad64e2..3ece556f 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,8 +1,47 @@ from django.urls import path + +from partner_programs.solution_action_api import SolutionActionView +from partner_programs.solution_case_action_api import SolutionCaseChangeView +from partner_programs.solution_history_api import SolutionHistoryView +from partner_programs.solution_member_action_api import SolutionMemberRemovalView +from partner_programs.solution_archive_action_api import SolutionArchiveView +from partner_programs.solution_registration_action_api import ( + SolutionRegistrationRemovalView, + SolutionRegistrationRemovalHistoryView, +) +from partner_programs.solution_publication_api import ( + PublishedSolutionListView, + PublishedSolutionDetailView, +) +from partner_programs.solution_attention_api import SolutionExpertReminderView +from partner_programs.solution_analytics_api import ( + SolutionCaseAnalyticsView, + SolutionManagerAnalyticsView, + SolutionManagerAssignmentsView, + SolutionManagerAssignmentView, + SolutionManagerSolutionsView, + SolutionManagerExportView, +) +from project_rates.solution_evaluation_api import ( + SolutionRatingsView, + SolutionRatingView, + SolutionExpertAssignmentsView, + SolutionAllocationView, + SolutionAllocationApplyView, + SolutionQuotaView, + SolutionResultsView, + SolutionResultsExportView, +) +from partner_programs.solution_submission_api import SolutionSubmitView +from partner_programs.solution_form_api import ManagedSolutionFormView, SolutionFormView +from partner_programs.solution_invitation_api import SolutionInvitationsView from partner_programs.solution_api import ( + SolutionCaptainView, SolutionContextView, SolutionDetailView, + SolutionLeaveView, SolutionListCreateView, + SolutionMemberView, ) from partner_programs.attention_views import AttentionActionView from partner_programs.report_views import ProgramReportView @@ -107,9 +146,81 @@ app_name = "partner_programs" urlpatterns = [ + path("/published-solutions/", PublishedSolutionListView.as_view()), + path( + "/published-solutions//", + PublishedSolutionDetailView.as_view(), + ), + path( + "/management/solution-analytics/attention/solutions-awaiting-evaluation/reminder-preview/", + SolutionExpertReminderView.as_view(), + ), + path( + "/management/solution-analytics/attention/solutions-awaiting-evaluation/reminders/", + SolutionExpertReminderView.as_view(reminder_action="confirm"), + ), + path( + "/management/solution-analytics/", SolutionManagerAnalyticsView.as_view() + ), + path( + "/management/solution-analytics/assignments/", + SolutionManagerAssignmentsView.as_view(), + ), + path( + "/management/solution-analytics/assignments//", + SolutionManagerAssignmentView.as_view(), + ), + path( + "/management/solution-analytics/solutions/", + SolutionManagerSolutionsView.as_view(), + ), + path( + "/management/solution-analytics/export/", + SolutionManagerExportView.as_view(), + ), + path( + "/management/solution-cases/", + SolutionCaseAnalyticsView.as_view(), + name="solution-case-analytics", + ), + path( + "/management/solution-evaluation/allocation/", + SolutionAllocationView.as_view(), + ), + path( + "/management/solution-evaluation/allocation/apply/", + SolutionAllocationApplyView.as_view(), + ), + path("/management/solution-evaluation/quota/", SolutionQuotaView.as_view()), + path("/management/solution-results/", SolutionResultsView.as_view()), + path( + "/management/solution-results/export/", + SolutionResultsExportView.as_view(), + ), + path("/solution-evaluation/", SolutionRatingsView.as_view()), + path( + "/solutions//evaluation/", SolutionRatingView.as_view() + ), + path( + "/management/solution-evaluation/experts//assignments/", + SolutionExpertAssignmentsView.as_view(), + ), + path("/solution-form/", SolutionFormView.as_view()), + path("/management/solution-form/", ManagedSolutionFormView.as_view()), path("/solution-context/", SolutionContextView.as_view()), path("/solutions/", SolutionListCreateView.as_view()), path("/solutions//", SolutionDetailView.as_view()), + path("/solutions//submit/", SolutionSubmitView.as_view()), + path( + "/solutions//members//", + SolutionMemberView.as_view(), + ), + path("/solutions//leave/", SolutionLeaveView.as_view()), + path("/solutions//captain/", SolutionCaptainView.as_view()), + path( + "/solutions//invitations/", + SolutionInvitationsView.as_view(), + ), path("/management/certificates/", CertificateSettingsView.as_view()), path("/management/certificates/assets/", CertificateAssetView.as_view()), path( @@ -137,6 +248,59 @@ "/certificates/download//", PublicCertificateDownloadView.as_view(), ), + path( + "/management/solutions//case/preview/", + SolutionCaseChangeView.as_view(), + ), + path( + "/management/solutions//members/remove/preview/", + SolutionMemberRemovalView.as_view(), + ), + path( + "/management/solutions//members/remove/apply/", + SolutionMemberRemovalView.as_view(apply_action=True), + ), + path( + "/management/solutions//history/", + SolutionHistoryView.as_view(), + ), + path( + "/management/solutions//history//", + SolutionHistoryView.as_view(), + ), + path( + "/management/solutions//case/apply/", + SolutionCaseChangeView.as_view(apply_action=True), + ), + path( + "/management/solutions/actions/preview/", + SolutionActionView.as_view(), + ), + path( + "/management/registrations/remove/preview/", + SolutionRegistrationRemovalView.as_view(), + ), + path( + "/management/registrations/remove/apply/", + SolutionRegistrationRemovalView.as_view(apply_action=True), + ), + path( + "/management/registrations/removals/", + SolutionRegistrationRemovalHistoryView.as_view(), + ), + path( + "/management/registrations/removals//", + SolutionRegistrationRemovalHistoryView.as_view(), + ), + path("/management/solutions/archive/preview/", SolutionArchiveView.as_view()), + path( + "/management/solutions/archive/apply/", + SolutionArchiveView.as_view(apply_action=True), + ), + path( + "/management/solutions/actions/apply/", + SolutionActionView.as_view(apply_action=True), + ), path( "/management/projects/actions/preview/", ManagementProjectActionPreviewView.as_view(), diff --git a/partner_programs/widget_views.py b/partner_programs/widget_views.py index acb161c3..82fa2f20 100644 --- a/partner_programs/widget_views.py +++ b/partner_programs/widget_views.py @@ -6,7 +6,10 @@ from rest_framework.views import APIView from partner_programs.models import PartnerProgram -from partner_programs.serializers.role_analytics import ProgramRoleWidgetSerializer +from partner_programs.serializers.role_analytics import ( + ProgramRoleWidgetSerializer, + SolutionProgramRoleWidgetSerializer, +) from partner_programs.services.role_analytics import build_program_role_widget @@ -17,4 +20,11 @@ def get(self, request, pk): program = get_object_or_404(PartnerProgram, pk=pk) # role/user_id/project_id клиента не используются для доступа или выбора. data = build_program_role_widget(program, request.user) - return Response(ProgramRoleWidgetSerializer(data).data) + serializer = ( + SolutionProgramRoleWidgetSerializer + if program.solution_storage_mode == "solution" + else ProgramRoleWidgetSerializer + ) + response = Response(serializer(data).data) + response["Cache-Control"] = "private, no-store" + return response diff --git a/procollab/celery.py b/procollab/celery.py index 71d371c2..2b75d8ac 100644 --- a/procollab/celery.py +++ b/procollab/celery.py @@ -38,6 +38,10 @@ "task": "partner_programs.tasks.publish_finished_program_projects_task", "schedule": crontab(minute=0, hour=6), }, + "publish_finished_program_solutions": { + "task": "partner_programs.tasks.publish_finished_program_solutions_task", + "schedule": crontab(minute=0, hour=6), + }, } if __name__ == "__main__": diff --git a/procollab/urls.py b/procollab/urls.py index c95c9313..1d98f4bf 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -11,6 +11,7 @@ TokenVerifyView, ) from users.authentication import ActivityTrackingJWTAuthentication +from partner_programs.solution_form_api import SolutionFormTemplatesView schema_view = get_schema_view( openapi.Info( @@ -49,6 +50,8 @@ path("vacancies/", include("vacancy.urls", namespace="vacancies")), path("core/", include("core.urls", namespace="core")), path("invites/", include("invites.urls", namespace="invites")), + path("solution-invitations/", include("partner_programs.solution_invitation_urls")), + path("solution-form-templates/", SolutionFormTemplatesView.as_view()), path("auth/", include(("users.urls", "users"), namespace="users")), path("chats/", include("chats.urls", namespace="chats")), # Модуль мероприятий сейчас отложен, поэтому публичные /events/ endpoints diff --git a/project_rates/admin.py b/project_rates/admin.py index 683acbc5..4b204cbc 100644 --- a/project_rates/admin.py +++ b/project_rates/admin.py @@ -12,6 +12,7 @@ from .models import Criteria, ProjectExpertAssignment, ProjectScore from .criteria_policy import locked +from . import solution_admin # noqa: F401 class CriteriaAdminForm(forms.ModelForm): diff --git a/project_rates/criteria_policy.py b/project_rates/criteria_policy.py index 8731b7b5..95857bac 100644 --- a/project_rates/criteria_policy.py +++ b/project_rates/criteria_policy.py @@ -29,11 +29,12 @@ def revision(program): def locked(program): - from project_rates.models import ProjectScore + from project_rates.models import ProjectScore, SolutionScore return ( bool(program.evaluation_criteria_locked_at) or ProjectScore.objects.filter(criteria__partner_program=program).exists() + or SolutionScore.objects.filter(criteria__partner_program=program).exists() ) diff --git a/project_rates/evaluation_math.py b/project_rates/evaluation_math.py new file mode 100644 index 00000000..8a9e5422 --- /dev/null +++ b/project_rates/evaluation_math.py @@ -0,0 +1,92 @@ +"""Общие правила завершенности, баллов и мест для проектов и решений.""" + +from decimal import Decimal, ROUND_HALF_UP + +from project_rates.validators import ProjectScoreValidator + + +def rounded(value): + return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def numeric(value): + return float(value) if value is not None else None + + +def valid_score(criterion, value): + try: + ProjectScoreValidator.validate( + criteria_type=criterion.type, + value=value, + criteria_min_value=criterion.min_value, + criteria_max_value=criterion.max_value, + ) + return True + except (TypeError, ValueError): + return False + + +def complete_values(criteria, values): + return bool(criteria) and all( + criterion.pk in values and valid_score(criterion, values[criterion.pk]) + for criterion in criteria + ) + + +def evaluation_totals(criteria, values_by_user): + """Сохраняет порядок округления существующего results_data, включая float totals.""" + numeric_criteria = [c for c in criteria if c.type in ("int", "float")] + evaluations = {} + for user_id, values in values_by_user.items(): + complete = complete_values(criteria, values) + total = ( + sum(Decimal(values[c.pk]) for c in numeric_criteria) + if complete and numeric_criteria + else None + ) + evaluations[user_id] = dict(complete=complete, total=numeric(total)) + completed = [pk for pk, row in evaluations.items() if row["complete"]] + score = ( + rounded( + sum(Decimal(str(evaluations[pk]["total"])) for pk in completed) + / len(completed) + ) + if completed and numeric_criteria + else None + ) + averages = { + str(c.pk): numeric( + rounded( + sum(Decimal(values_by_user[pk][c.pk]) for pk in completed) + / len(completed) + ) + ) + for c in numeric_criteria + if completed + } + maximum = ( + sum(Decimal(str(c.max_value)) for c in numeric_criteria) + if numeric_criteria and all(c.max_value is not None for c in numeric_criteria) + else None + ) + return dict( + evaluations=evaluations, + completed=len(completed), + score=numeric(score), + averages=averages, + maximum=numeric(maximum), + has_numeric_criteria=bool(numeric_criteria), + ) + + +def assign_places(rows, identity_key): + ready = sorted( + [row for row in rows if row["ready"]], + key=lambda row: (-row["score"], row["name"].lower(), row[identity_key]), + ) + last_score, place = None, 0 + for index, row in enumerate(ready, 1): + if row["score"] != last_score: + place = index + row["place"] = place + last_score = row["score"] diff --git a/project_rates/expert_cabinet.py b/project_rates/expert_cabinet.py index 05728535..37a05b53 100644 --- a/project_rates/expert_cabinet.py +++ b/project_rates/expert_cabinet.py @@ -11,7 +11,7 @@ from projects.models import Collaborator -def cabinet_projects(user, query, program_ids): +def cabinet_projects(user, query, program_ids, *, paginate=True, as_applications=False): own = annotated_assignment_queryset().filter( expert__user=user, partner_program__experts__user=user, @@ -46,6 +46,7 @@ def cabinet_projects(user, query, program_ids): user_id=user.pk, ) selected = selected.annotate( + cabinet_program_link_id=Subquery(linked.values("pk")[:1]), cabinet_rated_count=Subquery(scores.values("total")[:1]), cabinet_has_scores=Exists(own_scores), cabinet_own_team=Exists( @@ -56,14 +57,13 @@ def cabinet_projects(user, query, program_ids): ).select_related("partner_program", "project") offset, limit = query["offset"], query["limit"] end = offset + limit - assignments = list( - selected.order_by( - F("partner_program__datetime_evaluation_ends").asc(nulls_last=True), - "partner_program_id", - "project__name", - "pk", - )[offset:end] + selected = selected.order_by( + F("partner_program__datetime_evaluation_ends").asc(nulls_last=True), + "partner_program_id", + "project__name", + "pk", ) + assignments = list(selected[offset:end] if paginate else selected) programs = {a.partner_program_id: a.partner_program for a in assignments} cases = {} for program in programs.values(): @@ -109,6 +109,26 @@ def cabinet_projects(user, query, program_ids): "blocked_reason": reason, } ) + if as_applications: + from partner_programs.solution_api import iso + + row = rows[-1] + rows[-1] = dict( + application=dict( + kind="legacy_project", + program_id=program.pk, + project_id=project.pk, + program_link_id=assignment.cabinet_program_link_id, + name=project.name, + ), + assignment_id=assignment.pk, + program=dict(id=program.pk, name=program.name), + deadline=iso(program.datetime_evaluation_ends), + case_name=row["case_name"], + is_completed=row["is_completed"], + can_rate=row["can_rate"], + blocked_reason=row["blocked_reason"], + ) return { "count": count, "limit": limit, diff --git a/project_rates/migrations/0008_solutionexpertassignment_solutionscore_and_more.py b/project_rates/migrations/0008_solutionexpertassignment_solutionscore_and_more.py new file mode 100644 index 00000000..3473b888 --- /dev/null +++ b/project_rates/migrations/0008_solutionexpertassignment_solutionscore_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 4.2.11 on 2026-10-24 19:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0065_alter_customuser_birthday"), + ("partner_programs", "0031_solution_field_options"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("project_rates", "0007_expertinvitation_case_scope"), + ] + + operations = [ + migrations.CreateModel( + name="SolutionExpertAssignment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("datetime_created", models.DateTimeField(auto_now_add=True)), + ("datetime_updated", models.DateTimeField(auto_now=True)), + ( + "expert", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="solution_assignments", + to="users.expert", + ), + ), + ( + "partner_program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="solution_expert_assignments", + to="partner_programs.partnerprogram", + ), + ), + ( + "solution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="expert_assignments", + to="partner_programs.programsolution", + ), + ), + ], + ), + migrations.CreateModel( + name="SolutionScore", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.CharField(blank=True, max_length=50, null=True)), + ( + "criteria", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="solution_scores", + to="project_rates.criteria", + ), + ), + ( + "solution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="scores", + to="partner_programs.programsolution", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="solution_scores", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["solution", "user"], name="solution_score_rater_idx" + ) + ], + }, + ), + migrations.AddConstraint( + model_name="solutionscore", + constraint=models.UniqueConstraint( + fields=("criteria", "user", "solution"), + name="unique_solution_score_criterion_user", + ), + ), + migrations.AddIndex( + model_name="solutionexpertassignment", + index=models.Index( + fields=["partner_program", "solution"], + name="solution_assignment_scope_idx", + ), + ), + migrations.AddIndex( + model_name="solutionexpertassignment", + index=models.Index( + fields=["partner_program", "expert"], + name="solution_assignment_expert_idx", + ), + ), + migrations.AddConstraint( + model_name="solutionexpertassignment", + constraint=models.UniqueConstraint( + fields=("partner_program", "solution", "expert"), + name="unique_solution_expert_assignment", + ), + ), + ] diff --git a/project_rates/migrations/0009_alter_solutionexpertassignment_options_and_more.py b/project_rates/migrations/0009_alter_solutionexpertassignment_options_and_more.py new file mode 100644 index 00000000..2eab1cc9 --- /dev/null +++ b/project_rates/migrations/0009_alter_solutionexpertassignment_options_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.11 on 2026-10-24 20:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("project_rates", "0008_solutionexpertassignment_solutionscore_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="solutionexpertassignment", + options={ + "verbose_name": "Назначение эксперта на решение", + "verbose_name_plural": "Назначения экспертов на решения", + }, + ), + migrations.AlterModelOptions( + name="solutionscore", + options={ + "verbose_name": "Оценка решения", + "verbose_name_plural": "Оценки решений", + }, + ), + ] diff --git a/project_rates/migrations/0010_merge_solution_scoring.py b/project_rates/migrations/0010_merge_solution_scoring.py new file mode 100644 index 00000000..e241b1f6 --- /dev/null +++ b/project_rates/migrations/0010_merge_solution_scoring.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("project_rates", "0008_projectscore_value_1000"), + ("project_rates", "0009_alter_solutionexpertassignment_options_and_more"), + ] + + operations = [] diff --git a/project_rates/migrations/0011_solution_score_value_1000.py b/project_rates/migrations/0011_solution_score_value_1000.py new file mode 100644 index 00000000..55255993 --- /dev/null +++ b/project_rates/migrations/0011_solution_score_value_1000.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2026-10-24 21:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("project_rates", "0010_merge_solution_scoring"), + ] + + operations = [ + migrations.AlterField( + model_name="solutionscore", + name="value", + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 91163cfb..91686007 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -330,3 +330,6 @@ class Meta: fields=["invitation", "recipient"], name="unique_expert_joined_recipient" ) ] + + +from .solution_models import SolutionScore, SolutionExpertAssignment # noqa: E402,F401 diff --git a/project_rates/solution_admin.py b/project_rates/solution_admin.py new file mode 100644 index 00000000..91776310 --- /dev/null +++ b/project_rates/solution_admin.py @@ -0,0 +1,214 @@ +"""Те же стандартные Django admin операции над настоящими FK решений.""" + +from django import forms +from django.contrib import admin, messages +from django.contrib.admin.widgets import FilteredSelectMultiple +from django.core.exceptions import ValidationError +from django.db import transaction +from django.shortcuts import redirect +from django.urls import reverse + +from partner_programs.models import PartnerProgram +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_models import ProgramSolution +from project_rates.models import Criteria +from project_rates.solution_models import SolutionScore, SolutionExpertAssignment + + +def safe_id(value): + try: + number = int(value) + except (ValueError, TypeError): + return None + return number if 0 < number <= MAX_ID else None + + +class ProgramLockAdmin(admin.ModelAdmin): + def post_program_id(self, request, obj): + raise NotImplementedError + + @transaction.atomic + def changeform_view(self, request, object_id=None, form_url="", extra_context=None): + if request.method == "POST": + obj = self.get_object(request, object_id) if object_id else None + program_id = self.post_program_id(request, obj) + if program_id: + PartnerProgram.objects.select_for_update().filter(pk=program_id).first() + fresh = self.get_object(request, object_id) if object_id else None + if self.post_program_id(request, fresh) != program_id: + self.message_user( + request, + "Критерии изменились. Обновите форму оценки.", + level=messages.ERROR, + ) + return redirect(request.path) + return super().changeform_view(request, object_id, form_url, extra_context) + + +class SolutionAssignmentBulkForm(forms.ModelForm): + solutions = forms.ModelMultipleChoiceField( + queryset=ProgramSolution.objects.filter( + status="submitted", program__solution_storage_mode="solution" + ).order_by("name"), + label="Решения", + widget=FilteredSelectMultiple("Решения", is_stacked=False), + ) + + class Meta: + model = SolutionExpertAssignment + fields = ("partner_program", "expert") + labels = {"partner_program": "Программа", "expert": "Эксперт"} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["partner_program"].queryset = PartnerProgram.objects.filter( + solution_storage_mode="solution" + ) + program_id = safe_id(self.data.get("partner_program")) if self.is_bound else None + if program_id: + self.fields["solutions"].queryset = self.fields["solutions"].queryset.filter( + program_id=program_id + ) + self.fields["expert"].queryset = self.fields["expert"].queryset.filter( + programs__pk=program_id, user__is_active=True + ) + + def clean(self): + data = super().clean() + program, expert, solutions = ( + data.get("partner_program"), + data.get("expert"), + data.get("solutions"), + ) + if not program or not expert or not solutions: + return data + existing = set( + SolutionExpertAssignment.objects.filter( + partner_program=program, expert=expert + ).values_list("solution_id", flat=True) + ) + actionable = [solution for solution in solutions if solution.pk not in existing] + if not actionable: + raise forms.ValidationError( + "Все выбранные решения уже назначены этому эксперту." + ) + for solution in actionable: + candidate = SolutionExpertAssignment( + partner_program=program, expert=expert, solution=solution + ) + try: + candidate.clean() + except ValidationError as error: + raise forms.ValidationError(error.messages) from error + self.instance.solution = actionable[0] + self.actionable = actionable + self.skipped = len(solutions) - len(actionable) + return data + + +@admin.register(SolutionExpertAssignment) +class SolutionExpertAssignmentAdmin(ProgramLockAdmin): + list_display = ("id", "partner_program", "solution", "expert", "datetime_created") + list_filter = ("partner_program",) + search_fields = ("solution__name", "partner_program__name", "expert__user__email") + + def post_program_id(self, request, obj): + return ( + obj.partner_program_id + if obj + else safe_id(request.POST.get("partner_program")) + ) + + def get_form(self, request, obj=None, **kwargs): + if obj is None: + kwargs["form"] = SolutionAssignmentBulkForm + return super().get_form(request, obj, **kwargs) + + def get_fields(self, request, obj=None): + return ( + ("partner_program", "expert", "solutions") + if obj is None + else ("partner_program", "expert", "solution") + ) + + def get_readonly_fields(self, request, obj=None): + return ("partner_program", "expert", "solution") if obj else () + + def save_model(self, request, obj, form, change): + if change: + return super().save_model(request, obj, form, change) + obj.partner_program = form.cleaned_data["partner_program"] + obj.expert = form.cleaned_data["expert"] + obj.solution = form.actionable[0] + super().save_model(request, obj, form, change) + for solution in form.actionable[1:]: + SolutionExpertAssignment.objects.create( + partner_program=obj.partner_program, expert=obj.expert, solution=solution + ) + self.message_user( + request, + f"Создано назначений: {len(form.actionable)}. Уже существовало: {form.skipped}.", + level=messages.SUCCESS, + ) + + @transaction.atomic + def delete_view(self, request, object_id, extra_context=None): + obj = self.get_object(request, object_id) + if obj: + PartnerProgram.objects.select_for_update().get(pk=obj.partner_program_id) + if obj.has_scores(): + self.message_user( + request, + "Нельзя удалить назначение с сохраненными оценками.", + level=messages.ERROR, + ) + return redirect( + reverse( + "admin:project_rates_solutionexpertassignment_change", + args=[object_id], + ) + ) + return super().delete_view(request, object_id, extra_context) + + @transaction.atomic + def delete_queryset(self, request, queryset): + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=queryset.values("partner_program_id")) + .order_by("pk") + ) + skipped = 0 + for obj in queryset.order_by("pk"): + if obj.has_scores(): + skipped += 1 + else: + obj.delete() + if skipped: + self.message_user( + request, + f"Назначения с оценками сохранены: {skipped}.", + level=messages.WARNING, + ) + + +@admin.register(SolutionScore) +class SolutionScoreAdmin(ProgramLockAdmin): + list_display = ("id", "solution", "user", "criteria", "value") + list_filter = ("criteria__partner_program",) + search_fields = ("solution__name", "user__email", "criteria__name") + raw_id_fields = ("solution", "user", "criteria") + + def post_program_id(self, request, obj): + if obj: + return obj.criteria.partner_program_id + criterion_id = safe_id(request.POST.get("criteria")) + return ( + Criteria.objects.filter(pk=criterion_id) + .values_list("partner_program_id", flat=True) + .first() + if criterion_id + else None + ) + + def get_readonly_fields(self, request, obj=None): + return ("solution", "user", "criteria") if obj else () diff --git a/project_rates/solution_allocation.py b/project_rates/solution_allocation.py new file mode 100644 index 00000000..948b8347 --- /dev/null +++ b/project_rates/solution_allocation.py @@ -0,0 +1,352 @@ +"""Предпросмотр и применение назначений без подстановки solution_id в Project API.""" + +import hashlib +from collections import Counter, defaultdict + +from django.core import signing +from django.db import transaction + +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.management import person_name +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_forms import require_manager +from partner_programs.solution_team import program_for +from project_rates.allocation_alternatives import alternative_pairs +from project_rates.evaluation_math import complete_values +from project_rates.models import Criteria +from project_rates.solution_assignments import check_revision, revision, enable_workspace +from project_rates.solution_evaluation import snapshot_case, snapshot_name, team_user_ids +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.workspace import eligible, target + +SALT = "solution-expert-allocation" + + +def require_mode(program): + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + + +def assignment_snapshot(program, *, include_historical=True): + pairs = set( + program.solution_expert_assignments.values_list("solution_id", "expert_id") + ) + members = dict(program.experts.values_list("user_id", "pk")) + values = defaultdict(dict) + for solution_id, user_id, criterion_id, value in SolutionScore.objects.filter( + criteria__partner_program=program, + solution__program=program, + solution__archived_at__isnull=True, + ).values_list("solution_id", "user_id", "criteria_id", "value"): + if user_id in members: + pair = (solution_id, members[user_id]) + values[pair][criterion_id] = value + # Предпросмотр учитывает те же исторические назначения, что явный opt-in. + if include_historical and program.required_evaluators is None: + pairs.add(pair) + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + return [ + dict( + solution_id=sid, + expert_id=eid, + is_completed=complete_values(criteria, values[(sid, eid)]), + ) + for sid, eid in sorted(pairs) + ] + + +def load_plan(token, program): + try: + plan = signing.loads(token, salt=SALT, max_age=900) + except signing.BadSignature: + raise SolutionError( + "stale_plan", "Предпросмотр устарел. Рассчитайте распределение заново." + ) + if plan.get("program_id") != program.pk: + raise SolutionError( + "invalid_plan", "План принадлежит другой программе.", status=400 + ) + check_revision(program, plan["revision"]) + return plan + + +def allocation(program, data): + require_mode(program) + check_revision(program, data["revision"]) + all_experts = list( + program.experts.filter(user__is_active=True).select_related("user").order_by("pk") + ) + selected = set(data["expert_ids"]) + if (not selected and "case_pools" not in data) or selected - { + e.pk for e in all_experts + }: + raise SolutionError( + "invalid_assignments", + "Выберите активных экспертов этой программы.", + status=400, + ) + scopes = {scope.expert_id: scope for scope in program.expert_case_scopes.all()} + field = get_program_case_field(program) + cases = field.get_options_list() if field else [] + pools = None + if "case_pools" in data: + pools = {} + for pool in data["case_pools"]: + name, members = pool["case_name"], set(pool["expert_ids"]) + if name in pools or (name is not None and name not in cases): + raise SolutionError( + "invalid_assignments", + "Проверьте список кейсов распределения.", + status=400, + ) + if members - selected or any( + not eligible(scopes.get(eid), name) for eid in members + ): + raise SolutionError( + "invalid_assignments", + "Выберите подходящих экспертов для каждого кейса.", + status=400, + ) + pools[name] = members + experts = [expert for expert in all_experts if expert.pk in selected] + solutions = list(program.solutions.filter(status="submitted").order_by("pk")) + if "solution_ids" in data: + selected_solutions = set(data["solution_ids"]) + if selected_solutions - {row.pk for row in solutions}: + raise SolutionError( + "invalid_assignments", + "Выберите сданные решения этой программы.", + status=400, + ) + solutions = [row for row in solutions if row.pk in selected_solutions] + assignments = assignment_snapshot(program) + present = {(row["solution_id"], row["expert_id"]) for row in assignments} + counts = Counter(row["solution_id"] for row in assignments) + before = Counter(row["expert_id"] for row in assignments if not row["is_completed"]) + remaining = before.copy() + assigned_before = Counter(row["expert_id"] for row in assignments) + overrides, solution_ids = {}, {row.pk for row in solutions} + for row in data.get("assignment_overrides", []): + sid, ids = row["solution_id"], row["expert_ids"] + if sid not in solution_ids or sid in overrides or len(set(ids)) != len(ids): + raise SolutionError( + "invalid_assignments", + "Проверьте решения и экспертов в предпросмотре.", + status=400, + ) + overrides[sid] = set(ids) + additions, shortages, candidate_ids = [], [], {} + seed = data.get("seed") + for solution in solutions: + sid, case_name, own = ( + solution.pk, + snapshot_case(solution), + team_user_ids(solution), + ) + needed = max(0, target(program) - counts[sid]) + candidates = [ + expert + for expert in experts + if (sid, expert.pk) not in present + and expert.user_id not in own + and eligible(scopes.get(expert.pk), case_name) + and (pools is None or expert.pk in pools.get(case_name, set())) + ] + candidates.sort( + key=lambda expert: ( + remaining[expert.pk], + expert.pk + if seed is None + else hashlib.sha256(f"{seed}:{sid}:{expert.pk}".encode()).digest(), + ) + ) + candidate_ids[sid] = {expert.pk for expert in candidates} + if sid in overrides: + chosen = overrides[sid] + if chosen - candidate_ids[sid] or len(chosen) > needed: + raise SolutionError( + "invalid_assignments", + "Выберите доступных экспертов в пределах числа оценок решения.", + status=400, + ) + proposed = [expert for expert in candidates if expert.pk in chosen] + else: + proposed = candidates[:needed] + for expert in proposed: + additions.append( + dict( + solution_id=sid, + solution_name=snapshot_name(solution), + expert_id=expert.pk, + expert_name=person_name(expert.user), + case_name=case_name, + ) + ) + remaining[expert.pk] += 1 + if len(proposed) < needed: + shortages.append( + dict( + solution_id=sid, + solution_name=snapshot_name(solution), + missing=needed - len(proposed), + case_name=case_name, + ) + ) + if "previous_plan_token" in data: + if overrides: + raise SolutionError( + "invalid_assignments", + "Обновите предпросмотр после изменения состава.", + status=400, + ) + previous = load_plan(data["previous_plan_token"], program) + previous_pairs = { + (row["solution_id"], row["expert_id"]) for row in previous["additions"] + } + grouped = defaultdict(set) + for sid, eid in previous_pairs: + grouped[sid].add(eid) + if ( + set(grouped) - set(candidate_ids) + or previous["has_shortages"] + or any( + grouped[row.pk] - candidate_ids[row.pk] + or len(grouped[row.pk]) != max(0, target(program) - counts[row.pk]) + for row in solutions + ) + ): + raise SolutionError( + "invalid_assignments", + "Параметры распределения изменились. Обновите предпросмотр.", + status=400, + ) + load = before + Counter(eid for _, eid in previous_pairs) + different = alternative_pairs(previous_pairs, candidate_ids, load, seed or 0) + if different is None: + raise SolutionError( + "no_alternative", "Другого сбалансированного варианта нет." + ) + by_solution, by_expert = {row.pk: row for row in solutions}, { + row.pk: row for row in experts + } + additions = [ + dict( + solution_id=sid, + solution_name=snapshot_name(by_solution[sid]), + expert_id=eid, + expert_name=person_name(by_expert[eid].user), + case_name=snapshot_case(by_solution[sid]), + ) + for sid, eid in sorted(different) + ] + shortages = [] + pairs = {(row["solution_id"], row["expert_id"]) for row in additions} + added = Counter(eid for _, eid in pairs) + remaining = before + added + rev = revision(program) + token = signing.dumps( + dict( + program_id=program.pk, + revision=rev, + has_shortages=bool(shortages), + additions=[ + dict(solution_id=row["solution_id"], expert_id=row["expert_id"]) + for row in additions + ], + ), + salt=SALT, + compress=True, + ) + return dict( + revision=rev, + plan_token=token, + assignments=additions, + shortages=shortages, + can_reroll=not shortages + and alternative_pairs(pairs, candidate_ids, remaining) is not None, + load=[ + dict( + expert_id=e.pk, + name=person_name(e.user), + before=before[e.pk], + after=remaining[e.pk], + assigned_before=assigned_before[e.pk], + assigned_after=assigned_before[e.pk] + added[e.pk], + ) + for e in all_experts + ], + ) + + +@transaction.atomic +def preview_allocation(*, program_id, actor, data): + program = program_for(program_id, lock=True) + require_manager(program, actor) + return allocation(program, data) + + +@transaction.atomic +def apply_allocation(*, program_id, actor, plan_token): + program = program_for(program_id, lock=True) + require_manager(program, actor) + require_mode(program) + plan = load_plan(plan_token, program) + if plan["has_shortages"]: + raise SolutionError( + "expert_shortage", "Не хватает экспертов. Измените параметры распределения." + ) + enable_workspace(program) + existing = set( + program.solution_expert_assignments.values_list("solution_id", "expert_id") + ) + new_pairs = { + (row["solution_id"], row["expert_id"]) for row in plan["additions"] + } - existing + counts = Counter(sid for sid, _ in existing) + added = Counter(sid for sid, _ in new_pairs) + if any(counts[sid] + number > target(program) for sid, number in added.items()): + raise SolutionError( + "expert_limit_reached", "Достигнут лимит новых назначений экспертов." + ) + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=program, solution_id=sid, expert_id=eid + ) + for sid, eid in sorted(new_pairs) + ] + ) + return dict(program_id=program.pk, revision=revision(program), created=len(new_pairs)) + + +def quota_preview(program, number): + require_mode(program) + assignments = assignment_snapshot(program) + completed = Counter(row["solution_id"] for row in assignments if row["is_completed"]) + counts = Counter(row["solution_id"] for row in assignments) + ids = set(program.solutions.filter(status="submitted").values_list("pk", flat=True)) + return dict( + revision=revision(program), + required_evaluators=number, + submitted_solutions=len(ids), + solutions_missing_experts=sum(counts[sid] < number for sid in ids), + ready_before=sum(completed[sid] >= target(program) for sid in ids), + ready_after=sum(completed[sid] >= number for sid in ids), + missing_assignments=sum(max(0, number - counts[sid]) for sid in ids), + surplus_assignments=sum(max(0, counts[sid] - number) for sid in ids), + ) + + +@transaction.atomic +def save_quota(*, program_id, actor, revision, required_evaluators): + program = program_for(program_id, lock=True) + require_manager(program, actor) + require_mode(program) + check_revision(program, revision) + before = quota_preview(program, required_evaluators) + enable_workspace(program) + program.required_evaluators = required_evaluators + program.save(update_fields=["required_evaluators", "datetime_updated"]) + return {**before, "revision": quota_preview(program, required_evaluators)["revision"]} diff --git a/project_rates/solution_assignments.py b/project_rates/solution_assignments.py new file mode 100644 index 00000000..6c6631f9 --- /dev/null +++ b/project_rates/solution_assignments.py @@ -0,0 +1,227 @@ +"""Назначения решения: общая блокировка программы и ревизия реального состояния.""" + +import hashlib +import json +from collections import Counter + +from django.db import transaction +from django.http import Http404 + +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_forms import require_manager +from partner_programs.solution_schema import form_data +from partner_programs.solution_team import program_for +from project_rates.criteria_policy import revision as criteria_revision +from project_rates.models import Criteria +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.solution_evaluation import ( + snapshot_case, + snapshot_name, + team_user_ids, + metrics, + complete_values, +) +from project_rates.workspace import eligible, target + + +def revision(program): + payload = [ + program.pk, + program.required_evaluators, + program.max_project_rates, + program.is_distributed_evaluation, + list( + program.solutions.order_by("pk").values_list( + "pk", "version", "status", "captain_id", "name" + ) + ), + list( + program.experts.order_by("pk").values_list("pk", "user_id", "user__is_active") + ), + list( + program.solution_expert_assignments.order_by("pk").values_list( + "pk", "solution_id", "expert_id" + ) + ), + list( + SolutionScore.objects.filter(criteria__partner_program=program) + .order_by("pk") + .values_list("pk", "solution_id", "user_id", "criteria_id", "value") + ), + list( + program.expert_case_scopes.order_by("expert_id").values_list( + "expert_id", "all_cases", "case_names" + ) + ), + list( + program.fields.filter(name="case") + .order_by("pk") + .values_list("pk", "options", "solution_options") + ), + criteria_revision(program), + ] + return hashlib.sha256( + json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode() + ).hexdigest() + + +def check_revision(program, expected): + actual = revision(program) + if expected != actual: + raise SolutionError( + "stale_assignments", + "Данные изменились. Обновите список назначений.", + meta={"current_assignment_revision": actual}, + ) + + +def expert_for(program, expert_id): + if not 0 < expert_id <= 2**53 - 1: + raise Http404 + expert = ( + program.experts.filter(pk=expert_id, user__is_active=True) + .select_related("user") + .first() + ) + if expert is None: + raise Http404 + return expert + + +def assignment_options(program, expert): + solutions = list( + program.solutions.prefetch_related("members", "answers").order_by("pk") + ) + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + states = metrics(program, solutions, expert.user, criteria) + scope = program.expert_case_scopes.filter(expert=expert).first() + case_field = next( + (field for field in form_data(program)["fields"] if field["purpose"] == "case"), + None, + ) + rows = [] + for solution in solutions: + state = states[solution.pk] + case_name = snapshot_case( + solution, draft_field_id=case_field["id"] if case_field else None + ) + rows.append( + dict( + solution_id=solution.pk, + name=snapshot_name(solution), + case_name=case_name, + submitted=solution.status == "submitted", + assigned=state["assigned"] or bool(state["own"]), + protected=bool(state["own"]), + is_completed=solution.status == "submitted" + and complete_values(criteria, state["own"]), + own_team=expert.user_id in team_user_ids(solution), + eligible=eligible(scope, case_name), + ) + ) + return dict( + program_id=program.pk, + expert_id=expert.pk, + revision=revision(program), + solutions=rows, + ) + + +def enable_workspace(program): + """Тот же явный opt-in, включая сохранение назначений за историческими оценками.""" + if program.required_evaluators is not None: + return + program.required_evaluators = target(program) + program.is_distributed_evaluation = True + program.save( + update_fields=[ + "required_evaluators", + "is_distributed_evaluation", + "datetime_updated", + ] + ) + members = dict(program.experts.values_list("user_id", "pk")) + historical = ( + SolutionScore.objects.filter( + criteria__partner_program=program, + solution__program=program, + solution__archived_at__isnull=True, + ) + .values_list("solution_id", "user_id") + .distinct() + ) + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=program, + solution_id=solution_id, + expert_id=members[user_id], + ) + for solution_id, user_id in historical + if user_id in members + ], + ignore_conflicts=True, + ) + + +@transaction.atomic +def save_assignments(*, program_id, actor, expert_id, expected_revision, solution_ids): + program = program_for(program_id, lock=True) + require_manager(program, actor) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + check_revision(program, expected_revision) + expert = expert_for(program, expert_id) + if len(solution_ids) != len(set(solution_ids)): + raise SolutionError( + "invalid_assignments", "Решения не должны повторяться.", status=400 + ) + enable_workspace(program) + options = { + row["solution_id"]: row + for row in assignment_options(program, expert)["solutions"] + } + existing = set( + program.solution_expert_assignments.filter(expert=expert).values_list( + "solution_id", flat=True + ) + ) + wanted = set(solution_ids) + if wanted - options.keys(): + raise SolutionError( + "invalid_assignments", "Выберите решения этой программы.", status=400 + ) + protected = {pk for pk, row in options.items() if row["protected"]} + if protected - wanted: + raise SolutionError( + "scores_protected", "Нельзя снять назначение с сохраненными оценками." + ) + for pk in wanted - existing: + row = options[pk] + if not row["submitted"] or row["own_team"] or not row["eligible"]: + raise SolutionError( + "invalid_assignments", + "Можно назначить только сданное решение доступного кейса, кроме своей команды.", + status=400, + ) + counts = Counter( + program.solution_expert_assignments.values_list("solution_id", flat=True) + ) + if any(counts[pk] >= target(program) for pk in wanted - existing): + raise SolutionError( + "expert_limit_reached", "Достигнут лимит новых назначений экспертов." + ) + program.solution_expert_assignments.filter( + expert=expert, solution_id__in=existing - wanted + ).delete() + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=program, solution_id=pk, expert=expert + ) + for pk in sorted(wanted - existing) + ] + ) + return program, expert diff --git a/project_rates/solution_cabinet.py b/project_rates/solution_cabinet.py new file mode 100644 index 00000000..5b04576c --- /dev/null +++ b/project_rates/solution_cabinet.py @@ -0,0 +1,98 @@ +"""Общая страница назначений с явным видом заявки и единой пагинацией.""" + +from datetime import datetime, timezone + +from django.utils.dateparse import parse_datetime + +from partner_programs.models import PartnerProgram +from partner_programs.solution_api import iso +from project_rates.evaluation_math import complete_values +from project_rates.expert_cabinet import cabinet_projects +from project_rates.models import Criteria +from project_rates.solution_evaluation import metrics, rating_summary + + +def cabinet_applications(user, query): + programs = list( + PartnerProgram.objects.filter(experts__user=user).distinct().order_by("pk") + ) + legacy = cabinet_projects( + user, + query, + [p.pk for p in programs if p.solution_storage_mode == "legacy_project"], + paginate=False, + as_applications=True, + ) + rows = legacy["results"] + pending, completed, unavailable = ( + legacy["pending"], + legacy["completed"], + legacy["unavailable"], + ) + for program in programs: + if program.solution_storage_mode != "solution": + continue + assignments = list( + program.solution_expert_assignments.filter(expert__user=user) + .select_related("solution") + .order_by("pk") + ) + visible = [ + a + for a in assignments + if a.solution.program_id == program.pk and a.solution.status == "submitted" + ] + unavailable += len(assignments) - len(visible) + solutions = [a.solution for a in visible] + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + states = metrics(program, solutions, user, criteria) + for assignment in visible: + solution = assignment.solution + state = states[solution.pk] + done = complete_values(criteria, state["own"]) + completed += done + pending += not done + if done != (query["status"] == "completed"): + continue + if query.get("program_id") and program.pk != query["program_id"]: + continue + summary = rating_summary(program, solution, user, criteria, state) + if ( + query.get("q", "").casefold() + not in summary["application"]["name"].casefold() + ): + continue + rows.append( + dict( + application=summary["application"], + assignment_id=assignment.pk, + program=dict(id=program.pk, name=program.name), + deadline=iso(program.datetime_evaluation_ends), + case_name=summary["case_name"], + is_completed=summary["is_completed"], + can_rate=summary["can_rate"], + blocked_reason=summary["blocked_reason"], + ) + ) + rows.sort( + key=lambda row: ( + row["deadline"] is None, + parse_datetime(row["deadline"]) + if row["deadline"] + else datetime.max.replace(tzinfo=timezone.utc), + row["program"]["id"], + row["application"]["name"].casefold(), + row["assignment_id"], + row["application"]["kind"], + ) + ) + offset, limit = query["offset"], query["limit"] + return dict( + count=len(rows), + limit=limit, + offset=offset, + pending=pending, + completed=completed, + unavailable=unavailable, + results=rows[offset : offset + limit], # noqa: E203 (black slice formatting) + ) diff --git a/project_rates/solution_cabinet_api.py b/project_rates/solution_cabinet_api.py new file mode 100644 index 00000000..a9e9ff68 --- /dev/null +++ b/project_rates/solution_cabinet_api.py @@ -0,0 +1,24 @@ +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.solution_api import StrictSerializer +from partner_programs.solution_errors import MAX_ID +from project_rates.solution_evaluation_api import EvaluationAPIView +from project_rates.solution_cabinet import cabinet_applications + + +class CabinetQuery(StrictSerializer): + status = serializers.ChoiceField(choices=["pending", "completed"], default="pending") + q = serializers.CharField( # noqa: VNE001 + max_length=200, allow_blank=True, default="" + ) + program_id = serializers.IntegerField(min_value=1, max_value=MAX_ID, required=False) + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + + +class CabinetApplicationsView(EvaluationAPIView): + def get(self, request): + query = CabinetQuery(data=request.query_params) + query.is_valid(raise_exception=True) + return Response(cabinet_applications(request.user, query.validated_data)) diff --git a/project_rates/solution_evaluation.py b/project_rates/solution_evaluation.py new file mode 100644 index 00000000..8a348b7a --- /dev/null +++ b/project_rates/solution_evaluation.py @@ -0,0 +1,349 @@ +"""Оценка сданных решений с общими критериями и прежними режимами программы.""" + +from collections import defaultdict + +from django.db import transaction +from django.utils import timezone + +from partner_programs.solution_errors import SolutionError, issue +from partner_programs.solution_models import ProgramSolution +from partner_programs.solution_team import lock_solution, require_active +from project_rates.criteria_policy import revision as criteria_revision, mark_locked +from project_rates.models import Criteria +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.evaluation_math import complete_values, valid_score as valid_value +from users.models import Expert + + +def program_expert(program, actor, *, required=True): + expert = Expert.objects.filter( + programs=program, user=actor, user__is_active=True + ).first() + if not expert and required: + raise SolutionError( + "not_program_expert", + "Оценивание доступно экспертам этой программы.", + status=403, + ) + return expert + + +def expert_visible_solutions(program, actor): + query = ProgramSolution.objects.filter(program=program, status="submitted") + expert = program_expert(program, actor, required=False) + if not expert: + return query.none() + if program.is_distributed_evaluation: + query = query.filter( + expert_assignments__partner_program=program, expert_assignments__expert=expert + ) + return query.distinct() + + +def snapshot_name(solution): + snapshot = solution.submission_snapshot or {} + return snapshot.get("name", solution.name) + + +def snapshot_case(solution, *, draft_field_id=None): + snapshot = solution.submission_snapshot or {} + if solution.status == "submitted": + field = next( + ( + field + for field in snapshot.get("form", {}).get("fields", []) + if field["purpose"] == "case" + ), + None, + ) + value = next( + ( + answer["value"] + for answer in snapshot.get("answers", []) + if field and answer["field_id"] == field["id"] + ), + None, + ) + else: + value = next( + ( + answer.value + for answer in solution.answers.all() + if answer.field_id == draft_field_id + ), + None, + ) + return value if isinstance(value, str) and value.strip() else None + + +def team_user_ids(solution): + if solution.status == "submitted": + snapshot = solution.submission_snapshot or {} + return {snapshot.get("captain_id", solution.captain_id)} | { + row["user"]["id"] for row in snapshot.get("members", []) + } + return {solution.captain_id} | {member.user_id for member in solution.members.all()} + + +def metrics(program, solutions, actor, criteria): + ids = [solution.pk for solution in solutions] + values = defaultdict(lambda: defaultdict(dict)) + for solution_id, user_id, criterion_id, value in SolutionScore.objects.filter( + solution_id__in=ids, criteria__partner_program=program + ).values_list("solution_id", "user_id", "criteria_id", "value"): + values[solution_id][user_id][criterion_id] = value + assigned = set( + SolutionExpertAssignment.objects.filter( + partner_program=program, solution_id__in=ids, expert__user=actor + ).values_list("solution_id", flat=True) + ) + return { + solution.pk: dict( + own=values[solution.pk].get(actor.pk, {}), + assigned=solution.pk in assigned, + any_count=len(values[solution.pk]), + completed_count=sum( + complete_values(criteria, scores) + for scores in values[solution.pk].values() + ), + ) + for solution in solutions + } + + +def blocked_reason(program, solution, actor, criteria, state): + if solution.archived_at is not None: + return "Решение снято с участия." + if program.solution_storage_mode != "solution": + return "Программа использует заявки на основе проектов." + if solution.status != "submitted": + return "Сначала нужно сдать решение." + if ( + program.datetime_evaluation_ends + and timezone.now() > program.datetime_evaluation_ends + ): + return "Срок оценивания завершен." + if not criteria: + return "Организатор еще не настроил критерии." + if program.is_distributed_evaluation and not state["assigned"]: + return "Решение не назначено вам для оценки." + if program.required_evaluators is not None and actor.pk in team_user_ids(solution): + return "Нельзя оценивать решение своей команды." + if ( + program.required_evaluators is None + and program.max_project_rates + and state["any_count"] >= program.max_project_rates + and not state["own"] + ): + return "Лимит оценок достигнут." + return None + + +def rating_summary(program, solution, actor, criteria, state): + reason = blocked_reason(program, solution, actor, criteria, state) + return dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=snapshot_name(solution), + ), + case_name=snapshot_case(solution), + assigned=state["assigned"], + is_completed=complete_values(criteria, state["own"]), + can_rate=reason is None, + blocked_reason=reason, + ) + + +def rating_frame(solution, actor): + from partner_programs.solution_api import solution_data, iso + + program = solution.program + program_expert(program, actor) + require_active(solution) + if solution.status != "submitted": + raise SolutionError("solution_not_submitted", "Сначала нужно сдать решение.") + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + state = metrics(program, [solution], actor, criteria)[solution.pk] + reason = blocked_reason(program, solution, actor, criteria, state) + return dict( + program=dict(id=program.pk, name=program.name), + solution=solution_data(solution, actor), + criteria_revision=criteria_revision(program), + criteria=[ + dict( + id=c.pk, + name=c.name, + description=c.description, + type=c.type, + min_value=c.min_value, + max_value=c.max_value, + value=state["own"].get(c.pk), + ) + for c in criteria + ], + managed_evaluation=program.required_evaluators is not None, + can_rate=reason is None, + blocked_reason=reason, + is_completed=complete_values(criteria, state["own"]), + rated_count=state["completed_count"] + if program.required_evaluators is not None + else state["any_count"], + max_rates=program.max_project_rates, + required_evaluators=program.required_evaluators or program.max_project_rates or 1, + deadline=iso(program.datetime_evaluation_ends), + ) + + +def list_ratings(program, actor, query): + program_expert(program, actor) + solutions = list( + expert_visible_solutions(program, actor).select_related("program").order_by("pk") + ) + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + states = metrics(program, solutions, actor, criteria) + field = program.fields.filter(name="case").first() + case_options = field.get_options_list() if field else [] + if query["case_scope"] == "selected": + if query.get("case_name") not in case_options: + raise SolutionError( + "invalid_filter", "Выберите кейс этой программы.", status=400 + ) + rows = [] + for solution in solutions: + row = rating_summary(program, solution, actor, criteria, states[solution.pk]) + if query["search"].casefold() not in row["application"]["name"].casefold(): + continue + if query["assigned_only"] and not row["assigned"]: + continue + if query["case_scope"] == "selected" and row["case_name"] != query.get( + "case_name" + ): + continue + if query["case_scope"] == "without_case" and row["case_name"] is not None: + continue + rows.append(row) + completed = sum(row["is_completed"] for row in rows) + counts = dict(pending=len(rows) - completed, completed=completed) + if query["status"] != "all": + rows = [ + row for row in rows if row["is_completed"] == (query["status"] == "completed") + ] + rows.sort( + key=lambda row: ( + row["application"]["name"].casefold(), + row["application"]["solution_id"], + ) + ) + offset, limit = query["offset"], query["limit"] + return len(rows), rows[offset : offset + limit], counts, case_options # noqa: E203 + + +def score_issue(criterion_id, code, detail): + return { + **issue(f"scores.{criterion_id}.value", code, detail), + "criterion_id": criterion_id, + } + + +@transaction.atomic +def rate_solution(*, program_id, solution_id, actor, expected_criteria_revision, scores): + solution = lock_solution(program_id, solution_id, actor) + program = solution.program + program_expert(program, actor) + if program.solution_storage_mode != "solution": + raise SolutionError( + "solution_mode_unavailable", "Программа использует заявки на основе проектов." + ) + require_active(solution) + if solution.status != "submitted": + raise SolutionError("solution_not_submitted", "Сначала нужно сдать решение.") + actual_revision = criteria_revision(program) + if expected_criteria_revision != actual_revision: + raise SolutionError( + "criteria_changed", + "Критерии изменились. Обновите форму оценки.", + meta={"current_criteria_revision": actual_revision}, + ) + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + definitions = {c.pk: c for c in criteria} + state = metrics(program, [solution], actor, criteria)[solution.pk] + reason = blocked_reason(program, solution, actor, criteria, state) + if reason: + if ( + program.datetime_evaluation_ends + and timezone.now() > program.datetime_evaluation_ends + ): + code = "evaluation_closed" + elif not criteria: + code = "criteria_not_configured" + elif program.required_evaluators is not None and actor.pk in team_user_ids( + solution + ): + code = "own_team" + elif program.is_distributed_evaluation and not state["assigned"]: + code = "not_assigned" + else: + code = "max_rates_reached" + raise SolutionError( + code, reason, status=403 if code in ("own_team", "not_assigned") else 409 + ) + if not isinstance(scores, list) or not scores: + raise SolutionError( + "invalid_scores", "Передайте непустой список оценок.", status=400 + ) + seen, problems, values = set(), [], {} + for row in scores: + pk, value = row["criterion_id"], row["value"] + if pk in seen: + problems.append( + score_issue(pk, "duplicate_criterion", "Критерий повторяется в запросе.") + ) + continue + seen.add(pk) + if pk not in definitions: + problems.append( + score_issue( + pk, "unknown_criterion", "Критерий не относится к этой программе." + ) + ) + continue + if ( + value is not None and (not isinstance(value, str) or len(value) > 1000) + ) or not valid_value(definitions[pk], value): + problems.append( + score_issue( + pk, "invalid_value", "Проверьте тип ответа и допустимый диапазон." + ) + ) + continue + values[pk] = value + if program.required_evaluators is not None: + for criterion in criteria: + if criterion.type != "str" and criterion.pk not in seen: + problems.append( + score_issue( + criterion.pk, "required", "Заполните обязательный критерий." + ) + ) + elif criterion.type == "str" and criterion.pk not in seen: + values[criterion.pk] = "" + if problems: + raise SolutionError( + "invalid_scores", "Проверьте критерии оценки.", status=400, errors=problems + ) + SolutionScore.objects.bulk_create( + [ + SolutionScore(solution=solution, user=actor, criteria_id=pk, value=value) + for pk, value in values.items() + ], + update_conflicts=True, + update_fields=["value"], + unique_fields=["criteria", "user", "solution"], + ) + mark_locked(program) + from project_rates.solution_notifications import notify_solution_rated + + notify_solution_rated(solution) + return solution diff --git a/project_rates/solution_evaluation_api.py b/project_rates/solution_evaluation_api.py new file mode 100644 index 00000000..8480e5cc --- /dev/null +++ b/project_rates/solution_evaluation_api.py @@ -0,0 +1,300 @@ +from django.db import transaction +from django.http import Http404, HttpResponse +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.solution_api import ( + CommandInteger, + NameField, + SolutionAPIView, + StrictSerializer, + iso, +) +from partner_programs.solution_errors import MAX_ID +from partner_programs.solution_forms import require_manager +from partner_programs.solution_team import program_for, visible_solutions +from project_rates import ( + solution_evaluation, + solution_assignments, + solution_allocation, + solution_results, +) + + +class RatingListQuery(StrictSerializer): + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) + offset = serializers.IntegerField(min_value=0, default=0) + search = serializers.CharField(allow_blank=True, max_length=200, default="") + case_scope = serializers.ChoiceField( + choices=["all", "selected", "without_case"], default="all" + ) + case_name = serializers.CharField( + allow_blank=True, max_length=500, required=False, trim_whitespace=False + ) + status = serializers.ChoiceField( + choices=["all", "pending", "completed"], default="all" + ) + assigned_only = serializers.BooleanField(default=False) + + +class RatingValueSerializer(StrictSerializer): + criterion_id = CommandInteger(min_value=1, max_value=MAX_ID) + value = NameField( + allow_null=True, allow_blank=True, max_length=1000, trim_whitespace=False + ) + + +class RatingWrite(StrictSerializer): + expected_criteria_revision = serializers.RegexField( + r"^[a-f0-9]{64}$", trim_whitespace=False + ) + scores = RatingValueSerializer(many=True, allow_empty=False) + + +class AssignmentWrite(StrictSerializer): + revision = serializers.RegexField(r"^[a-f0-9]{64}$", trim_whitespace=False) + solution_ids = serializers.ListField( + child=CommandInteger(min_value=1, max_value=MAX_ID), allow_empty=True + ) + + +def no_query(request): + serializer = StrictSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + +class EvaluationAPIView(SolutionAPIView): + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "private, no-store" + response["Referrer-Policy"] = "no-referrer" + return response + + +class SolutionRatingsView(EvaluationAPIView): + def get(self, request, pk): + query = RatingListQuery(data=request.query_params) + query.is_valid(raise_exception=True) + values = query.validated_data + count, rows, counts, case_options = solution_evaluation.list_ratings( + program_for(pk), request.user, values + ) + limit, offset = values["limit"], values["offset"] + + def link(start): + params = request.query_params.copy() + params["limit"], params["offset"] = limit, start + return request.build_absolute_uri("?" + params.urlencode()) + + return Response( + dict( + count=count, + next=link(offset + limit) if offset + limit < count else None, + previous=link(max(0, offset - limit)) if offset > 0 else None, + results=rows, + evaluation_counts=counts, + case_options=case_options, + ) + ) + + +class SolutionRatingView(EvaluationAPIView): + def get(self, request, pk, solution_id): + no_query(request) + program = program_for(pk) + if not 0 < solution_id <= MAX_ID: + raise Http404 + solution = visible_solutions(program, request.user).filter(pk=solution_id).first() + if solution is None: + raise Http404 + solution.program = program + return Response(solution_evaluation.rating_frame(solution, request.user)) + + @transaction.atomic + def post(self, request, pk, solution_id): + no_query(request) + serializer = RatingWrite(data=request.data) + serializer.is_valid(raise_exception=True) + solution = solution_evaluation.rate_solution( + program_id=pk, + solution_id=solution_id, + actor=request.user, + **serializer.validated_data, + ) + return Response(solution_evaluation.rating_frame(solution, request.user)) + + +class SolutionExpertAssignmentsView(EvaluationAPIView): + def get(self, request, pk, expert_id): + no_query(request) + program = program_for(pk) + require_manager(program, request.user) + expert = solution_assignments.expert_for(program, expert_id) + return Response(solution_assignments.assignment_options(program, expert)) + + @transaction.atomic + def put(self, request, pk, expert_id): + no_query(request) + serializer = AssignmentWrite(data=request.data) + serializer.is_valid(raise_exception=True) + program, expert = solution_assignments.save_assignments( + program_id=pk, + expert_id=expert_id, + actor=request.user, + expected_revision=serializer.validated_data["revision"], + solution_ids=serializer.validated_data["solution_ids"], + ) + return Response(solution_assignments.assignment_options(program, expert)) + + +def unique_ids(values): + if len(values) != len(set(values)): + raise serializers.ValidationError("Идентификаторы не должны повторяться.") + return values + + +def identifiers(*, required=True, maximum=1000): + return serializers.ListField( + child=CommandInteger(min_value=1, max_value=MAX_ID), + max_length=maximum, + required=required, + validators=[unique_ids], + ) + + +class CasePoolWrite(StrictSerializer): + case_name = NameField( + allow_null=True, allow_blank=True, max_length=500, trim_whitespace=False + ) + expert_ids = identifiers() + + +class SolutionOverrideWrite(StrictSerializer): + solution_id = CommandInteger(min_value=1, max_value=MAX_ID) + expert_ids = identifiers() + + +class AllocationWrite(StrictSerializer): + revision = serializers.RegexField(r"^[a-f0-9]{64}$", trim_whitespace=False) + expert_ids = identifiers() + solution_ids = identifiers(required=False, maximum=10000) + case_pools = CasePoolWrite(many=True, required=False, max_length=1000) + seed = CommandInteger(min_value=0, max_value=2147483647, required=False) + previous_plan_token = NameField( + max_length=500000, required=False, trim_whitespace=False + ) + assignment_overrides = SolutionOverrideWrite( + many=True, required=False, max_length=10000 + ) + + +class AllocationApply(StrictSerializer): + plan_token = NameField(max_length=500000, trim_whitespace=False) + + +class QuotaQuery(StrictSerializer): + required_evaluators = serializers.IntegerField(min_value=1, max_value=1000) + + +class QuotaWrite(StrictSerializer): + required_evaluators = CommandInteger(min_value=1, max_value=1000) + revision = serializers.RegexField(r"^[a-f0-9]{64}$", trim_whitespace=False) + + +class ResultsQuery(StrictSerializer): + search = serializers.CharField(allow_blank=True, max_length=200, default="") + case_scope = serializers.ChoiceField( + choices=["all", "selected", "without_case"], default="all" + ) + case_name = serializers.CharField( + allow_blank=True, max_length=500, required=False, trim_whitespace=False + ) + order = serializers.ChoiceField(choices=["asc", "desc"], default="desc") + status = serializers.ChoiceField( + choices=["all", "complete", "pending"], default="all" + ) + + +class ResultsExportQuery(ResultsQuery): + scope = serializers.ChoiceField(choices=["current", "all"], default="current") + + +class SolutionAllocationView(EvaluationAPIView): + def post(self, request, pk): + no_query(request) + serializer = AllocationWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return Response( + solution_allocation.preview_allocation( + program_id=pk, actor=request.user, data=serializer.validated_data + ) + ) + + +class SolutionAllocationApplyView(EvaluationAPIView): + def post(self, request, pk): + no_query(request) + serializer = AllocationApply(data=request.data) + serializer.is_valid(raise_exception=True) + return Response( + solution_allocation.apply_allocation( + program_id=pk, actor=request.user, **serializer.validated_data + ) + ) + + +class SolutionQuotaView(EvaluationAPIView): + def get(self, request, pk): + serializer = QuotaQuery(data=request.query_params) + serializer.is_valid(raise_exception=True) + program = program_for(pk) + require_manager(program, request.user) + return Response( + solution_allocation.quota_preview( + program, serializer.validated_data["required_evaluators"] + ) + ) + + def put(self, request, pk): + no_query(request) + serializer = QuotaWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return Response( + solution_allocation.save_quota( + program_id=pk, actor=request.user, **serializer.validated_data + ) + ) + + +class SolutionResultsView(EvaluationAPIView): + def get(self, request, pk): + serializer = ResultsQuery(data=request.query_params) + serializer.is_valid(raise_exception=True) + program = program_for(pk) + require_manager(program, request.user) + solution_allocation.require_mode(program) + data = solution_results.results_data( + program, {**serializer.validated_data, "scope": "current"} + ) + data["generated_at"] = iso(data["generated_at"]) + return Response(data) + + +class SolutionResultsExportView(EvaluationAPIView): + def get(self, request, pk): + from partner_programs.services.results_excel import export_solution_results + + serializer = ResultsExportQuery(data=request.query_params) + serializer.is_valid(raise_exception=True) + program = program_for(pk) + require_manager(program, request.user) + solution_allocation.require_mode(program) + data = solution_results.results_data(program, serializer.validated_data) + response = HttpResponse( + export_solution_results(data), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{pk}-solution-results.xlsx"' + return response diff --git a/project_rates/solution_models.py b/project_rates/solution_models.py new file mode 100644 index 00000000..e6c77bd3 --- /dev/null +++ b/project_rates/solution_models.py @@ -0,0 +1,221 @@ +"""Оценки и реальные назначения новой сущности; общие Criteria/Expert сохраняются.""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models, transaction + +from partner_programs.models import PartnerProgram +from partner_programs.solution_models import ProgramSolution +from project_rates.validators import ProjectScoreValidator + + +class SolutionScore(models.Model): + criteria = models.ForeignKey( + "project_rates.Criteria", on_delete=models.CASCADE, related_name="solution_scores" + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="solution_scores" + ) + solution = models.ForeignKey( + "partner_programs.ProgramSolution", + on_delete=models.CASCADE, + related_name="scores", + ) + value = models.CharField(max_length=1000, null=True, blank=True) + + class Meta: + verbose_name = "Оценка решения" + verbose_name_plural = "Оценки решений" + constraints = [ + models.UniqueConstraint( + fields=["criteria", "user", "solution"], + name="unique_solution_score_criterion_user", + ) + ] + indexes = [ + models.Index(fields=["solution", "user"], name="solution_score_rater_idx") + ] + + def clean(self): + super().clean() + from project_rates.models import Criteria + + criterion = Criteria.objects.filter(pk=self.criteria_id).first() + solution = ProgramSolution.all_objects.filter(pk=self.solution_id).first() + if solution and solution.archived_at is not None: + raise ValidationError({"solution": "Решение снято с участия."}) + if criterion and solution and criterion.partner_program_id != solution.program_id: + raise ValidationError({"criteria": "Критерий относится к другой программе."}) + if solution and solution.status != "submitted": + raise ValidationError({"solution": "Сначала нужно сдать решение."}) + if criterion: + try: + ProjectScoreValidator.validate( + criteria_type=criterion.type, + value=self.value, + criteria_min_value=criterion.min_value, + criteria_max_value=criterion.max_value, + ) + except (TypeError, ValueError) as error: + raise ValidationError({"value": str(error)}) from error + + @transaction.atomic + def save(self, *args, **kwargs): + from project_rates.models import Criteria + from project_rates.criteria_policy import mark_locked + + criterion = Criteria.objects.get(pk=self.criteria_id) + program = PartnerProgram.objects.select_for_update().get( + pk=criterion.partner_program_id + ) + if Criteria.objects.get(pk=self.criteria_id).partner_program_id != program.pk: + raise ValidationError( + {"criteria": "Критерии изменились. Обновите форму оценки."}, + code="criteria_changed", + ) + self.full_clean() + result = super().save(*args, **kwargs) + mark_locked(program) + return result + + +class SolutionAssignmentQuerySet(models.QuerySet): + @transaction.atomic + def delete(self): + program_ids = self.values_list("partner_program_id", flat=True).distinct() + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=program_ids) + .order_by("pk") + ) + rows = list(self.select_related("expert").order_by("pk")) + if any(row.has_scores() for row in rows): + raise ValidationError( + "Нельзя снять назначение с сохраненными оценками.", + code="scores_protected", + ) + return super().delete() + + +class SolutionExpertAssignment(models.Model): + partner_program = models.ForeignKey( + "partner_programs.PartnerProgram", + on_delete=models.CASCADE, + related_name="solution_expert_assignments", + ) + solution = models.ForeignKey( + "partner_programs.ProgramSolution", + on_delete=models.CASCADE, + related_name="expert_assignments", + ) + expert = models.ForeignKey( + "users.Expert", on_delete=models.CASCADE, related_name="solution_assignments" + ) + datetime_created = models.DateTimeField(auto_now_add=True) + datetime_updated = models.DateTimeField(auto_now=True) + objects = SolutionAssignmentQuerySet.as_manager() + + class Meta: + verbose_name = "Назначение эксперта на решение" + verbose_name_plural = "Назначения экспертов на решения" + constraints = [ + models.UniqueConstraint( + fields=["partner_program", "solution", "expert"], + name="unique_solution_expert_assignment", + ) + ] + indexes = [ + models.Index( + fields=["partner_program", "solution"], + name="solution_assignment_scope_idx", + ), + models.Index( + fields=["partner_program", "expert"], + name="solution_assignment_expert_idx", + ), + ] + + def has_scores(self): + return SolutionScore.objects.filter( + solution_id=self.solution_id, + user_id=self.expert.user_id, + criteria__partner_program_id=self.partner_program_id, + ).exists() + + def clean(self): + super().clean() + from users.models import Expert + from project_rates.models import ExpertCaseScope + from project_rates.workspace import eligible, target + + if not self.partner_program_id or not self.solution_id or not self.expert_id: + return + + solution = ProgramSolution.all_objects.filter(pk=self.solution_id).first() + if solution and solution.archived_at is not None: + raise ValidationError({"solution": "Решение снято с участия."}) + expert = Expert.objects.filter( + pk=self.expert_id, programs__pk=self.partner_program_id, user__is_active=True + ).first() + if expert is None: + raise ValidationError( + {"expert": "Выберите активного эксперта этой программы."} + ) + if not solution or solution.program_id != self.partner_program_id: + raise ValidationError({"solution": "Решение относится к другой программе."}) + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + changed = previous is None or ( + previous.partner_program_id, + previous.solution_id, + previous.expert_id, + ) != (self.partner_program_id, self.solution_id, self.expert_id) + if previous and changed: + raise ValidationError("Назначение нельзя переназначить; создайте новое.") + if not changed: + return + if solution.status != "submitted": + raise ValidationError({"solution": "Назначать можно только сданное решение."}) + snapshot = solution.submission_snapshot or {} + team_ids = {row["user"]["id"] for row in snapshot.get("members", [])} + team_ids.add(snapshot.get("captain_id", solution.captain_id)) + if expert.user_id in team_ids: + raise ValidationError( + {"expert": "Нельзя назначить эксперта на свою команду."} + ) + case_field = next( + ( + field + for field in snapshot.get("form", {}).get("fields", []) + if field["purpose"] == "case" + ), + None, + ) + case_name = next( + ( + answer["value"] + for answer in snapshot.get("answers", []) + if case_field and answer["field_id"] == case_field["id"] + ), + None, + ) + scope = ExpertCaseScope.objects.filter( + partner_program_id=self.partner_program_id, expert=expert + ).first() + if not eligible(scope, case_name): + raise ValidationError({"expert": "Кейс недоступен этому эксперту."}) + count = ( + type(self) + .objects.filter(partner_program_id=self.partner_program_id, solution=solution) + .count() + ) + if count >= target(solution.program): + raise ValidationError("Достигнут лимит новых назначений экспертов.") + + @transaction.atomic + def save(self, *args, **kwargs): + PartnerProgram.objects.select_for_update().get(pk=self.partner_program_id) + self.full_clean() + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + return type(self).objects.filter(pk=self.pk).delete() diff --git a/project_rates/solution_notifications.py b/project_rates/solution_notifications.py new file mode 100644 index 00000000..ece3a3b3 --- /dev/null +++ b/project_rates/solution_notifications.py @@ -0,0 +1,18 @@ +from django.db import transaction + +from project_rates.solution_evaluation import snapshot_name +from vacancy.mapping import MessageTypeEnum, SolutionRatedParams +from vacancy.tasks import send_email + + +def notify_solution_rated(solution): + payload = SolutionRatedParams( + message_type=MessageTypeEnum.SOLUTION_RATED.value, + user_id=solution.captain_id, + schema_id=2, + program_name=solution.program.name, + program_id=solution.program_id, + solution_name=snapshot_name(solution), + solution_id=solution.pk, + ) + transaction.on_commit(lambda: send_email.delay(payload), robust=True) diff --git a/project_rates/solution_results.py b/project_rates/solution_results.py new file mode 100644 index 00000000..3d0c36db --- /dev/null +++ b/project_rates/solution_results.py @@ -0,0 +1,132 @@ +"""Результаты решений с прежней математикой; сериализация доступна только через scoped API.""" + +from collections import defaultdict + +from django.utils import timezone + +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.management import person_name +from partner_programs.solution_errors import SolutionError +from project_rates.evaluation_math import evaluation_totals, assign_places +from project_rates.models import Criteria +from project_rates.solution_evaluation import snapshot_case, snapshot_name +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore + + +def results_data(program, query, *, solution_id=None): + criteria = list(Criteria.objects.filter(partner_program=program).order_by("pk")) + field = get_program_case_field(program) + options = field.get_options_list() if field else [] + if query["case_scope"] == "selected" and query.get("case_name") not in options: + raise SolutionError("invalid_filter", "Выберите существующий кейс.", status=400) + solutions = program.solutions.filter(status="submitted").order_by("pk") + if solution_id is not None: + solutions = solutions.filter(pk=solution_id) + solutions = list(solutions) + ids = [row.pk for row in solutions] + values, people = defaultdict(dict), defaultdict(dict) + for score in SolutionScore.objects.filter( + criteria__partner_program=program, solution_id__in=ids + ).select_related("user"): + values[(score.solution_id, score.user_id)][score.criteria_id] = score.value + people[score.solution_id][score.user_id] = score.user + for assignment in SolutionExpertAssignment.objects.filter( + partner_program=program, solution_id__in=ids + ).select_related("expert__user"): + people[assignment.solution_id][assignment.expert.user_id] = assignment.expert.user + required = program.required_evaluators or program.max_project_rates or 1 + buckets = {name: [] for name in [*options, None]} + for solution in solutions: + totals = evaluation_totals( + criteria, + {user_id: values[(solution.pk, user_id)] for user_id in people[solution.pk]}, + ) + experts = [ + dict( + user_id=user_id, + name=person_name(user), + email=user.email, + avatar=user.avatar or None, + **totals["evaluations"][user_id], + values=[ + dict( + criterion_id=c.pk, value=values[(solution.pk, user_id)].get(c.pk) + ) + for c in criteria + ], + ) + for user_id, user in sorted(people[solution.pk].items()) + ] + complete = totals["completed"] >= required + case_name = snapshot_case(solution) + buckets.setdefault(case_name, []).append( + dict( + application=dict( + kind="solution", + program_id=program.pk, + solution_id=solution.pk, + name=snapshot_name(solution), + ), + solution_id=solution.pk, + name=snapshot_name(solution), + case_name=case_name, + completed=totals["completed"], + required=required, + evaluation_complete=complete, + ready=complete and totals["score"] is not None, + score=totals["score"], + place=None, + averages=totals["averages"], + experts=experts, + ) + ) + groups = [] + for case_name, rows in buckets.items(): + if solution_id is None: + assign_places(rows, "solution_id") + if query["scope"] != "all": + if query["case_scope"] == "selected" and case_name != query.get("case_name"): + continue + if query["case_scope"] == "without_case" and case_name is not None: + continue + rows = [row for row in rows if query["search"].lower() in row["name"].lower()] + if query.get("status", "all") != "all": + rows = [ + row + for row in rows + if row["evaluation_complete"] == (query["status"] == "complete") + ] + sign = -1 if query["order"] == "desc" else 1 + rows.sort( + key=lambda row: ( + not row["evaluation_complete"], + row["score"] is None, + sign * (row["score"] or 0), + row["name"].lower(), + row["solution_id"], + ) + ) + if rows: + groups.append(dict(case_name=case_name, solutions=rows)) + base = evaluation_totals(criteria, {}) + return dict( + program_id=program.pk, + program_name=program.name, + generated_at=timezone.now(), + required_evaluators=required, + maximum=base["maximum"], + has_numeric_criteria=base["has_numeric_criteria"], + case_options=options, + groups=groups, + criteria=[ + dict( + id=c.pk, + name=c.name, + description=c.description, + type=c.type, + min_value=c.min_value, + max_value=c.max_value, + ) + for c in criteria + ], + ) diff --git a/project_rates/tests/test_solution_admin.py b/project_rates/tests/test_solution_admin.py new file mode 100644 index 00000000..e06bf53e --- /dev/null +++ b/project_rates/tests/test_solution_admin.py @@ -0,0 +1,229 @@ +from html.parser import HTMLParser +from unittest.mock import patch + +from django.test import TestCase, override_settings +from django.urls import reverse + +from partner_programs.tests.helpers import create_user +from partner_programs.models import PartnerProgram +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates.models import Criteria +from project_rates.solution_models import SolutionScore, SolutionExpertAssignment +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +class HiddenInputs(HTMLParser): + def __init__(self, content): + super().__init__() + self.values = {} + self.feed(content.decode()) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "input" and attrs.get("type") == "hidden" and attrs.get("name"): + self.values[attrs["name"]] = attrs.get("value", "") + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionAdminTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.staff = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(self.staff) + + def test_bulk_add_uses_real_solutions_and_preserves_atomic_validation(self): + first, second = self.another_solution(), self.another_solution() + url = reverse("admin:project_rates_solutionexpertassignment_add") + response = self.client.post( + url, + dict( + partner_program=self.program.pk, + expert=self.second.expert.pk, + solutions=[first.pk, second.pk], + _save="save", + ), + ) + self.assertEqual(response.status_code, 302, response.content[:400]) + self.assertEqual( + set( + SolutionExpertAssignment.objects.filter( + expert=self.second.expert + ).values_list("solution_id", flat=True) + ), + {first.pk, second.pk}, + ) + draft = self.another_solution(submitted=False) + response = self.client.post( + url, + dict( + partner_program=self.program.pk, + expert=self.second.expert.pk, + solutions=[self.solution.pk, draft.pk], + _save="save", + ), + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + SolutionExpertAssignment.objects.filter( + expert=self.second.expert, solution=self.solution + ).exists() + ) + + def test_bulk_add_cannot_exceed_quota_or_assign_own_team(self): + url = reverse("admin:project_rates_solutionexpertassignment_add") + response = self.client.post( + url, + dict( + partner_program=self.program.pk, + expert=self.second.expert.pk, + solutions=[self.solution.pk], + _save="save", + ), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + from users.models import Expert + + self.program.required_evaluators = 2 + self.program.save() + self.captain.is_active = True + self.captain.save(update_fields=["is_active"]) + own, _ = Expert.objects.get_or_create(user=self.captain) + own.programs.add(self.program) + response = self.client.post( + url, + dict( + partner_program=self.program.pk, + expert=own.pk, + solutions=[self.solution.pk], + _save="save", + ), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_admin_draft_name_edit_advances_version(self): + draft = self.another_solution(submitted=False) + before = draft.version + url = reverse("admin:partner_programs_programsolution_change", args=[draft.pk]) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = HiddenInputs(response.content).values + data.update(name="Новое название", _save="save") + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302, response.content[:400]) + draft.refresh_from_db() + self.assertEqual(draft.version, before + 1) + self.assertEqual(draft.name, "Новое название") + + def test_partial_scores_protect_single_and_bulk_assignment_deletion(self): + self.program.required_evaluators = 2 + self.program.save() + removable = SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.second.expert, + ) + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=self.criteria[0], value="0" + ) + response = self.client.get( + reverse( + "admin:project_rates_solutionexpertassignment_delete", + args=[self.assignment.pk], + ) + ) + self.assertEqual(response.status_code, 302) + url = reverse("admin:project_rates_solutionexpertassignment_changelist") + response = self.client.post( + url, + { + "action": "delete_selected", + "_selected_action": [self.assignment.pk, removable.pk], + "post": "yes", + }, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + SolutionExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + self.assertFalse( + SolutionExpertAssignment.objects.filter(pk=removable.pk).exists() + ) + + def test_submitted_admin_view_cannot_change_snapshot_status_or_name(self): + url = reverse( + "admin:partner_programs_programsolution_change", args=[self.solution.pk] + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotRegex(response.content.decode(), r']*\bname="name"') + data = HiddenInputs(response.content).values + data.update(name="Изменено", status="draft", submitted_at="", _save="save") + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302, response.content[:400]) + original = self.solution.submission_snapshot + self.solution.refresh_from_db() + self.assertEqual(self.solution.status, "submitted") + self.assertEqual(self.solution.name, original["name"]) + self.assertEqual(self.solution.submission_snapshot, original) + self.assertEqual( + self.client.get( + reverse( + "admin:partner_programs_programsolution_delete", + args=[self.solution.pk], + ) + ).status_code, + 403, + ) + + def test_score_admin_validates_value_and_permanently_locks_criteria(self): + url = reverse("admin:project_rates_solutionscore_add") + data = dict( + solution=self.solution.pk, + user=self.expert.pk, + criteria=self.criteria[0].pk, + value="NaN", + _save="save", + ) + self.assertEqual(self.client.post(url, data).status_code, 200) + self.assertFalse(SolutionScore.objects.exists()) + data["value"] = "0" + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302, response.content[:400]) + self.assertEqual(SolutionScore.objects.get().value, "0") + self.program.refresh_from_db() + self.assertIsNotNone(self.program.evaluation_criteria_locked_at) + + def test_direct_score_write_rechecks_criterion_program_after_lock(self): + other = SubmissionFixture() + other.ready() + self.assertEqual(other.submit().status_code, 200) + other.solution.refresh_from_db() + select = PartnerProgram.objects.select_for_update + + def criterion_moved_before_lock(): + Criteria.objects.filter(pk=self.criteria[0].pk).update( + partner_program=other.program + ) + return select() + + from django.core.exceptions import ValidationError + + with patch.object( + PartnerProgram.objects, + "select_for_update", + side_effect=criterion_moved_before_lock, + ): + with self.assertRaises(ValidationError): + SolutionScore.objects.create( + solution=other.solution, + user=self.expert, + criteria=self.criteria[0], + value="0", + ) + self.assertFalse(SolutionScore.objects.exists()) + self.program.refresh_from_db() + other.program.refresh_from_db() + self.assertIsNone(self.program.evaluation_criteria_locked_at) + self.assertIsNone(other.program.evaluation_criteria_locked_at) diff --git a/project_rates/tests/test_solution_allocation.py b/project_rates/tests/test_solution_allocation.py new file mode 100644 index 00000000..12fde0b4 --- /dev/null +++ b/project_rates/tests/test_solution_allocation.py @@ -0,0 +1,300 @@ +from unittest.mock import patch + +from django.core import signing +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) + +from partner_programs.tests.test_solution_forms import call +from project_rates import solution_allocation, solution_assignments +from project_rates.models import ExpertCaseScope +from project_rates.solution_models import SolutionExpertAssignment, SolutionScore +from project_rates.tests.helpers import create_rate_expert +from users.models import Expert +from project_rates.tests.test_solution_evaluation import EvaluationFixture +from project_rates.tests import test_solution_evaluation as evaluation_tests + + +class AllocationFixture(EvaluationFixture): + def setup_allocation(self): + self.seed_evaluation() + self.program.required_evaluators = 2 + self.program.save() + self.root = f"/programs/{self.program.pk}/management/solution-evaluation/" + + def plan(self, **changes): + command = dict( + revision=solution_assignments.revision(self.program), + expert_ids=[self.second.expert.pk], + ) + command.update(changes) + return call(self.manager, "post", self.root + "allocation/", command) + + def apply(self, token, actor=None): + return call( + actor or self.manager, + "post", + self.root + "allocation/apply/", + dict(plan_token=token), + ) + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionAllocationTests(AllocationFixture, TestCase): + def setUp(self): + self.setup_allocation() + + def test_preview_is_read_only_and_apply_uses_actual_solution_identity_once(self): + response = self.plan() + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("SolutionAllocationPlan", response.data) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + self.assertEqual(response.data["assignments"][0]["solution_id"], self.solution.pk) + self.assertNotIn("project_id", str(response.data)) + self.assertFalse(response.data["shortages"]) + applied = self.apply(response.data["plan_token"]) + self.assertEqual(applied.status_code, 200, applied.data) + self.assert_schema("SolutionAssignmentReceipt", applied.data) + self.assertEqual(applied.data["created"], 1) + self.assertEqual(SolutionExpertAssignment.objects.count(), 2) + self.assert_error( + self.apply(response.data["plan_token"]), "stale_assignments", 409 + ) + + def test_shortage_never_applies_partial_plan(self): + self.program.required_evaluators = 3 + self.program.save() + response = self.plan() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["shortages"][0]["missing"], 1) + self.assert_error(self.apply(response.data["plan_token"]), "expert_shortage", 409) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_case_pools_and_scopes_constrain_new_assignments(self): + scope = ExpertCaseScope.objects.create( + partner_program=self.program, + expert=self.second.expert, + all_cases=False, + case_names=[], + ) + self.assertTrue(self.plan().data["shortages"]) + self.assert_error( + self.plan( + case_pools=[ + dict(case_name="Задача 1", expert_ids=[self.second.expert.pk]) + ] + ), + "invalid_assignments", + 400, + ) + scope.case_names = ["Задача 1"] + scope.save() + response = self.plan( + case_pools=[dict(case_name="Задача 1", expert_ids=[self.second.expert.pk])] + ) + self.assertEqual(len(response.data["assignments"]), 1) + self.assertTrue(self.plan(case_pools=[]).data["shortages"]) + self.assert_error( + self.plan(case_pools=[dict(case_name="Foreign", expert_ids=[])]), + "invalid_assignments", + 400, + ) + + def test_own_team_is_excluded_even_when_expert_is_in_case_pool(self): + self.captain.is_active = True + self.captain.save(update_fields=["is_active"]) + expert, _ = Expert.objects.get_or_create(user=self.captain) + expert.programs.add(self.program) + response = self.plan( + expert_ids=[expert.pk], + case_pools=[dict(case_name="Задача 1", expert_ids=[expert.pk])], + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["assignments"], []) + self.assertEqual(response.data["shortages"][0]["missing"], 1) + + def test_foreign_and_draft_solutions_and_duplicate_ids_are_rejected(self): + draft = self.another_solution(submitted=False) + for changes in [ + dict(solution_ids=[draft.pk]), + dict(solution_ids=[2**53 - 1]), + dict(expert_ids=[2**53 - 1]), + dict(expert_ids=[self.second.expert.pk] * 2), + dict(solution_ids=[self.solution.pk] * 2), + dict(expert_ids=[]), + dict(assignment_overrides=[dict(solution_id=draft.pk, expert_ids=[])]), + dict( + assignment_overrides=[ + dict( + solution_id=self.solution.pk, + expert_ids=[self.second.expert.pk] * 2, + ) + ] + ), + ]: + with self.subTest(changes=changes): + self.assertEqual(self.plan(**changes).status_code, 400) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_changed_membership_case_scope_or_solution_invalidates_plan(self): + token = self.plan().data["plan_token"] + self.program.experts.remove(self.second.expert) + self.assert_error(self.apply(token), "stale_assignments", 409) + self.program.experts.add(self.second.expert) + token = self.plan().data["plan_token"] + ExpertCaseScope.objects.create( + partner_program=self.program, + expert=self.second.expert, + all_cases=False, + case_names=[], + ) + self.assert_error(self.apply(token), "stale_assignments", 409) + + def test_token_tampering_other_domain_foreign_program_and_expiry(self): + token = self.plan().data["plan_token"] + self.assert_error(self.apply(token + "broken"), "stale_plan", 409) + self.assert_error( + self.apply( + signing.dumps(dict(program_id=self.program.pk), salt="expert-allocation") + ), + "stale_plan", + 409, + ) + self.assert_error( + self.apply( + signing.dumps( + dict(program_id=self.program.pk + 1), salt=solution_allocation.SALT + ) + ), + "invalid_plan", + 400, + ) + with patch("django.core.signing.time.time", return_value=10): + expired = self.plan().data["plan_token"] + self.assert_error(self.apply(expired), "stale_plan", 409) + + def test_reroll_and_override_preserve_existing_assignments_and_balanced_load(self): + third = create_rate_expert(program=self.program) + ids = [self.second.expert.pk, third.expert.pk] + first = self.plan(expert_ids=ids) + self.assertTrue(first.data["can_reroll"]) + reroll = self.plan(expert_ids=ids, previous_plan_token=first.data["plan_token"]) + self.assertEqual(reroll.status_code, 200, reroll.data) + self.assertNotEqual(first.data["assignments"], reroll.data["assignments"]) + override = self.plan( + expert_ids=ids, + assignment_overrides=[ + dict(solution_id=self.solution.pk, expert_ids=[third.expert.pk]) + ], + ) + self.assertEqual(override.data["assignments"][0]["expert_id"], third.expert.pk) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_quota_preview_and_save_keep_surplus_and_scores(self): + SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.second.expert, + ) + self.rate() + response = call(self.manager, "get", self.root + "quota/?required_evaluators=1") + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("SolutionQuota", response.data) + self.assertEqual(response.data["ready_before"], 0) + self.assertEqual(response.data["ready_after"], 1) + self.assertEqual(response.data["surplus_assignments"], 1) + saved = call( + self.manager, + "put", + self.root + "quota/", + dict(revision=response.data["revision"], required_evaluators=1), + ) + self.assertEqual(saved.status_code, 200, saved.data) + self.assertEqual(SolutionExpertAssignment.objects.count(), 2) + self.assertEqual(SolutionScore.objects.count(), 3) + self.assertEqual(self.assign([self.solution.pk], self.second).status_code, 200) + + def test_legacy_scores_are_virtual_assignments_in_preview_then_preserved_on_opt_in( + self, + ): + self.assignment.delete() + self.program.required_evaluators = None + self.program.is_distributed_evaluation = False + self.program.max_project_rates = 2 + self.program.save() + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=self.criteria[0], value="5" + ) + response = self.plan(expert_ids=[self.expert.expert.pk, self.second.expert.pk]) + self.assertEqual(len(response.data["assignments"]), 1) + self.assertEqual( + response.data["assignments"][0]["expert_id"], self.second.expert.pk + ) + self.assertFalse(SolutionExpertAssignment.objects.exists()) + self.assertEqual(self.apply(response.data["plan_token"]).status_code, 200) + self.assertEqual(SolutionExpertAssignment.objects.count(), 2) + + def test_shared_invitation_workspace_uses_solution_revision_and_backfills_own_scores( + self, + ): + from project_rates.workspace import revision, enable_workspace + + self.assignment.delete() + self.program.required_evaluators = None + self.program.is_distributed_evaluation = False + self.program.save() + before = revision(self.program) + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=self.criteria[0], value="1" + ) + self.assertNotEqual(before, revision(self.program)) + self.assertEqual( + revision(self.program), solution_assignments.revision(self.program) + ) + enable_workspace(self.program) + self.assertTrue( + SolutionExpertAssignment.objects.filter( + solution=self.solution, expert=self.expert.expert + ).exists() + ) + + def test_results_are_private_manager_only_and_match_contract(self): + self.rate() + url = f"/programs/{self.program.pk}/management/solution-results/" + response = call(self.manager, "get", url) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("SolutionResults", response.data) + self.assertEqual(response["Cache-Control"], "private, no-store") + for actor in [self.expert, self.captain, self.second]: + self.assertEqual(call(actor, "get", url).status_code, 403) + self.assertEqual( + self.apply(self.plan().data["plan_token"], actor).status_code, 403 + ) + self.assertEqual(call(self.manager, "get", url + "?scope=all").status_code, 400) + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionAllocationRaceTests(AllocationFixture, TransactionTestCase): + race = evaluation_tests.SolutionEvaluationRaceTests.race + + def setUp(self): + self.setup_allocation() + + @skipUnlessDBFeature("has_select_for_update") + def test_two_plans_cannot_both_fill_one_slot(self): + third = create_rate_expert(program=self.program) + plans = [ + self.plan(expert_ids=[user.expert.pk]).data["plan_token"] + for user in [self.second, third] + ] + actions = [ + lambda token=token: solution_allocation.apply_allocation( + program_id=self.program.pk, actor=self.manager, plan_token=token + ) + for token in plans + ] + self.assertCountEqual(self.race(*actions), ["ok", "stale_assignments"]) + self.assertEqual(SolutionExpertAssignment.objects.count(), 2) diff --git a/project_rates/tests/test_solution_cabinet.py b/project_rates/tests/test_solution_cabinet.py new file mode 100644 index 00000000..4c57de27 --- /dev/null +++ b/project_rates/tests/test_solution_cabinet.py @@ -0,0 +1,123 @@ +from django.test import TestCase, override_settings + +from partner_programs.tests.test_solution_forms import call +from project_rates.models import ProjectExpertAssignment +from project_rates.solution_models import SolutionExpertAssignment +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_project, + link_project_to_program, +) +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionCabinetTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.legacy = create_rate_program( + is_competitive=True, required_evaluators=1, is_distributed_evaluation=True + ) + self.expert.expert.programs.add(self.legacy) + self.project = create_rate_project(name="Другая работа") + self.legacy.users.add( + self.project.leader, through_defaults={"partner_program_data": {}} + ) + self.link = link_project_to_program(self.legacy, self.project) + self.link.submitted = True + self.link.save() + self.legacy_assignment = ProjectExpertAssignment.objects.create( + partner_program=self.legacy, project=self.project, expert=self.expert.expert + ) + self.cabinet_url = "/evaluation/applications/" + + def page(self, query="", actor=None): + response = call(actor or self.expert, "get", self.cabinet_url + query) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("CabinetApplications", response.data) + return response.data + + def test_mixed_ids_are_discriminated_and_single_pagination_follows_global_count(self): + page = self.page("?limit=1") + other = self.page("?limit=1&offset=1") + self.assertEqual(page["count"], 2) + self.assertEqual(page["pending"], 2) + rows = page["results"] + other["results"] + self.assertEqual( + {row["application"]["kind"] for row in rows}, {"solution", "legacy_project"} + ) + legacy = next( + row for row in rows if row["application"]["kind"] == "legacy_project" + ) + solution = next(row for row in rows if row["application"]["kind"] == "solution") + self.assertEqual(legacy["application"]["project_id"], self.project.pk) + self.assertEqual(solution["application"]["solution_id"], self.solution.pk) + self.assertEqual(legacy["application"]["program_link_id"], self.link.pk) + self.assertNotIn("project_id", solution["application"]) + self.assertEqual(self.page("?offset=100")["results"], []) + + def test_completion_search_and_program_filter_preserve_global_tabs_counts(self): + self.rate() + completed = self.page("?status=completed") + self.assertEqual(completed["count"], 1) + self.assertEqual(completed["completed"], 1) + self.assertEqual(completed["pending"], 1) + self.assertEqual(completed["results"][0]["application"]["kind"], "solution") + self.assertEqual(self.page(f"?program_id={self.legacy.pk}")["count"], 1) + missing = self.page("?q=no-match") + self.assertEqual(missing["count"], 0) + self.assertEqual(missing["pending"], 1) + + def test_actual_membership_and_assignment_scope_hide_foreign_work(self): + self.assertEqual(self.page(actor=self.second)["count"], 0) + self.program.experts.remove(self.expert.expert) + page = self.page() + self.assertEqual(page["count"], 1) + self.assertEqual(page["results"][0]["application"]["kind"], "legacy_project") + self.assertEqual(self.page(actor=self.captain)["count"], 0) + + def test_old_programs_and_include_projects_contract_stay_unchanged(self): + old = call(self.expert, "get", "/evaluation/programs/").data + self.assertEqual(set(old), {"results", "has_access"}) + own = next(row for row in old["results"] if row["id"] == self.program.pk) + self.assertEqual(own["assigned"], 1) + self.assertEqual(own["remaining"], 1) + included = call(self.expert, "get", "/evaluation/programs/?include=projects").data + self.assertEqual(set(included), {"results", "has_access", "projects"}) + self.assertEqual(included["projects"]["count"], 1) + self.assertIn("project_id", included["projects"]["results"][0]) + + def test_unavailable_draft_is_counted_without_exposing_it(self): + draft = self.another_solution(submitted=False) + SolutionExpertAssignment.objects.bulk_create( + [ + SolutionExpertAssignment( + partner_program=self.program, + solution=draft, + expert=self.expert.expert, + ) + ] + ) + page = self.page() + self.assertEqual(page["count"], 2) + self.assertEqual(page["unavailable"], 1) + self.assertFalse( + any( + row["application"].get("solution_id") == draft.pk + for row in page["results"] + ) + ) + + def test_query_is_strict_and_private(self): + for query in [ + "?limit=0", + "?offset=-1", + "?program_id=0", + "?solution_id=1", + "?status=all", + ]: + self.assertEqual( + call(self.expert, "get", self.cabinet_url + query).status_code, 400 + ) + response = call(self.expert, "get", self.cabinet_url) + self.assertEqual(response["Cache-Control"], "private, no-store") diff --git a/project_rates/tests/test_solution_evaluation.py b/project_rates/tests/test_solution_evaluation.py new file mode 100644 index 00000000..7d3b1fdf --- /dev/null +++ b/project_rates/tests/test_solution_evaluation.py @@ -0,0 +1,574 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +from django.core.exceptions import ValidationError +from django.db import close_old_connections +from django.http import Http404 +from django.test import ( + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.utils import timezone + +from partner_programs.solution_api import create_solution +from partner_programs.solution_errors import SolutionError +from partner_programs.solution_submission import submit_solution +from partner_programs.solution_schema import form_data +from partner_programs.tests.helpers import ( + create_user, + create_program_member, + create_partner_program, +) +from partner_programs.tests.test_solution_forms import call +from partner_programs.tests.test_solution_submission import SubmissionFixture +from project_rates import solution_assignments, solution_evaluation +from project_rates.criteria_policy import revision, locked +from project_rates.models import Criteria, ExpertCaseScope, ProjectScore +from project_rates.solution_models import SolutionScore, SolutionExpertAssignment +from project_rates.tests.helpers import create_rate_expert, create_rate_criteria +from users.models import Expert + + +class EvaluationFixture(SubmissionFixture): + def seed_evaluation(self): + self.ready() + response = self.submit() + assert response.status_code == 200, response.data + self.solution.refresh_from_db() + self.expert = create_rate_expert(program=self.program) + self.second = create_rate_expert(program=self.program) + Criteria.objects.filter(partner_program=self.program).delete() + self.criteria = [ + create_rate_criteria(self.program, type="int", min_value=0, max_value=10), + create_rate_criteria(self.program, type="bool"), + create_rate_criteria(self.program, type="str"), + ] + self.program.required_evaluators = 1 + self.program.is_distributed_evaluation = True + self.program.save() + self.assignment = SolutionExpertAssignment.objects.create( + partner_program=self.program, + solution=self.solution, + expert=self.expert.expert, + ) + self.evaluation_url = self.url + "evaluation/" + self.list_url = f"/programs/{self.program.pk}/solution-evaluation/" + + def scores(self, **changes): + values = dict( + expected_criteria_revision=revision(self.program), + scores=[ + dict(criterion_id=self.criteria[0].pk, value="0"), + dict(criterion_id=self.criteria[1].pk, value="False"), + ], + ) + values.update(changes) + return values + + def rate(self, actor=None, **changes): + return call( + actor or self.expert, "post", self.evaluation_url, self.scores(**changes) + ) + + def options_url(self, actor=None): + return ( + f"/programs/{self.program.pk}/management/solution-evaluation/experts/" + f"{(actor or self.expert).expert.pk}/assignments/" + ) + + def options(self, actor=None): + response = call(self.manager, "get", self.options_url(actor)) + assert response.status_code == 200, response.data + return response.data + + def assign(self, ids, actor=None, **changes): + command = dict(revision=self.options(actor)["revision"], solution_ids=ids) + command.update(changes) + return call(self.manager, "put", self.options_url(actor), command) + + def assert_schema(self, name, data): + from jsonschema import Draft202012Validator, FormatChecker + + spec = json.loads( + ( + Path(__file__).resolve().parents[2] + / "docs/contracts/program-solutions-v1/openapi.json" + ).read_text(encoding="utf-8") + ) + Draft202012Validator( + {"components": spec["components"], "$ref": f"#/components/schemas/{name}"}, + format_checker=FormatChecker(), + ).validate(data) + + def another_solution(self, *, submitted=True): + captain = create_user() + create_program_member(self.program, user=captain) + solution, _ = create_solution( + self.program.pk, captain, dict(name="Вторая работа") + ) + if submitted: + from partner_programs.solution_answers import update_solution + + update_solution( + program_id=self.program.pk, + solution_id=solution.pk, + actor=captain, + expected_version=solution.version, + expected_form_revision=form_data(self.program)["revision"], + answers=[ + dict(field_id=self.fields["select"]["id"], value="Задача 1"), + dict(field_id=self.fields["textarea"]["id"], value="Ответ"), + ], + ) + solution.refresh_from_db() + submit_solution( + program_id=self.program.pk, + solution_id=solution.pk, + actor=captain, + expected_version=solution.version, + expected_form_revision=form_data(self.program)["revision"], + ) + solution.refresh_from_db() + return solution + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionEvaluationTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + + def archive_after_opening_rating(self, *, distributed): + from partner_programs.services import solution_archive_actions + + self.program.is_distributed_evaluation = distributed + self.program.save(update_fields=["is_distributed_evaluation"]) + # One user may manage the program and evaluate another team's work. + # Manager visibility must not turn an archive into a quota/assignment error. + self.program.managers.add(self.expert) + self.assertEqual(self.rate().status_code, 200) + self.assertEqual(call(self.expert, "get", self.evaluation_url).status_code, 200) + command = self.scores() + preview = solution_archive_actions.preview( + self.program.pk, self.manager, dict(ids=[self.solution.pk]) + ) + solution_archive_actions.apply( + self.program.pk, + self.manager, + dict(ids=[self.solution.pk], preview_token=preview["preview_token"]), + ) + return command + + def assert_archived_rating_rejected_without_changing_saved_work(self, distributed): + command = self.archive_after_opening_rating(distributed=distributed) + before = list(SolutionScore.objects.filter(solution=self.solution).values()) + self.solution.refresh_from_db() + snapshot = self.solution.submission_snapshot + response = call(self.expert, "post", self.evaluation_url, command) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "solution_archived") + self.assert_schema("Error", response.data) + stale = call( + self.expert, + "post", + self.evaluation_url, + {**command, "expected_criteria_revision": "0" * 64}, + ) + self.assertEqual(stale.status_code, 409, stale.data) + self.assertEqual(stale.data["code"], "solution_archived") + self.solution.refresh_from_db() + self.assertEqual(self.solution.submission_snapshot, snapshot) + self.assertEqual( + list(SolutionScore.objects.filter(solution=self.solution).values()), before + ) + self.assertFalse(self.solution.expert_assignments.exists()) + + def test_open_rating_of_archived_work_reports_archive_instead_of_quota(self): + self.assert_archived_rating_rejected_without_changing_saved_work(False) + + def test_distributed_rating_of_archived_work_reports_archive_instead_of_assignment( + self, + ): + self.assert_archived_rating_rejected_without_changing_saved_work(True) + + def test_archived_work_stays_hidden_from_experts_without_retained_access(self): + command = self.archive_after_opening_rating(distributed=False) + self.program.managers.remove(self.expert) + response = call(self.expert, "post", self.evaluation_url, command) + self.assertEqual(response.status_code, 404, response.data) + self.assertEqual(response.data["code"], "not_found") + self.assertEqual(call(self.expert, "get", self.evaluation_url).status_code, 404) + + def test_frame_is_snapshot_and_only_own_values_with_real_solution_identity(self): + SolutionScore.objects.create( + solution=self.solution, user=self.second, criteria=self.criteria[0], value="9" + ) + response = call(self.expert, "get", self.evaluation_url) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RatingFrame", response.data) + self.assertEqual(response.data["solution"]["id"], self.solution.pk) + self.assertTrue(response.data["solution"]["form"]["is_snapshot"]) + self.assertTrue(response.data["can_rate"]) + self.assertEqual([c["value"] for c in response.data["criteria"]], [None] * 3) + self.assertNotIn("project_id", str(response.data)) + self.assertFalse(ProjectScore.objects.exists()) + + def test_managed_save_zero_false_optional_comment_edit_and_permanent_freeze(self): + response = self.rate() + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RatingFrame", response.data) + self.assertTrue(response.data["is_completed"]) + self.assertEqual(response.data["rated_count"], 1) + self.assertEqual( + [c["value"] for c in response.data["criteria"]], ["0", "False", ""] + ) + self.assertEqual(self.rate().status_code, 200) + self.assertEqual(SolutionScore.objects.count(), 3) + self.assertTrue(locked(self.program)) + SolutionScore.objects.all().delete() + self.program.refresh_from_db() + self.assertTrue(locked(self.program)) + self.criteria[0].name = "Changed" + with self.assertRaises(ValidationError): + self.criteria[0].save() + + def test_invalid_values_foreign_duplicates_and_missing_are_atomic_and_address_criterion( + self, + ): + for value in ["NaN", "Infinity", "-1", "11", "1.5", None]: + with self.subTest(value=value): + response = self.rate( + scores=[dict(criterion_id=self.criteria[0].pk, value=value)] + ) + self.assert_error(response, "invalid_scores", 400) + self.assert_schema("Error", response.data) + self.assertEqual( + response.data["errors"][0]["criterion_id"], self.criteria[0].pk + ) + self.assertIsNone(response.data["errors"][0]["field_id"]) + self.assertFalse(SolutionScore.objects.exists()) + rows = self.scores()["scores"] + for extra, code in [ + (rows[0], "duplicate_criterion"), + (dict(criterion_id=2**53 - 1, value="1"), "unknown_criterion"), + ]: + response = self.rate(scores=[*rows, extra]) + self.assert_error(response, "invalid_scores", 400) + self.assertEqual(response.data["errors"][0]["code"], code) + self.assertFalse(SolutionScore.objects.exists()) + + def test_comment_matches_current_shared_form_limit_and_rejects_1001_atomically(self): + comment = "А" * 1000 + scores = [ + *self.scores()["scores"], + dict(criterion_id=self.criteria[2].pk, value=comment), + ] + response = self.rate(scores=scores) + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RatingFrame", response.data) + self.assertEqual(response.data["criteria"][2]["value"], comment) + self.assertTrue(response.data["is_completed"]) + before = list( + SolutionScore.objects.order_by("pk").values_list("criteria_id", "value") + ) + scores[0]["value"] = "10" + scores[2]["value"] = comment + "А" + invalid = self.rate(scores=scores) + self.assertEqual(invalid.status_code, 400) + self.assert_schema("Error", invalid.data) + with self.assertRaises(SolutionError): + solution_evaluation.rate_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.expert, + **self.scores(scores=scores), + ) + self.assertEqual( + list( + SolutionScore.objects.order_by("pk").values_list("criteria_id", "value") + ), + before, + ) + stored = SolutionScore.objects.get( + solution=self.solution, user=self.expert, criteria=self.criteria[2] + ) + stored.value += "А" + with self.assertRaises(ValidationError): + stored.save() + stored.refresh_from_db() + self.assertEqual(stored.value, comment) + + def test_strict_wire_values_and_long_comment_do_not_coerce_or_write(self): + for value in [0, False, {}, "x" * 1001]: + response = self.rate( + scores=[dict(criterion_id=self.criteria[2].pk, value=value)] + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertFalse(SolutionScore.objects.exists()) + self.assertEqual(self.rate(scores=[]).status_code, 400) + + def test_list_own_counts_apply_filters_before_status_and_pagination(self): + self.rate() + second = self.another_solution() + response = self.assign([self.solution.pk, second.pk]) + self.assertEqual(response.status_code, 200, response.data) + response = call(self.expert, "get", self.list_url + "?status=pending&limit=1") + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RatingPage", response.data) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["evaluation_counts"], dict(pending=1, completed=1)) + self.assertEqual( + response.data["results"][0]["application"]["solution_id"], second.pk + ) + response = call( + self.expert, "get", self.list_url + "?status=completed&search=Вторая" + ) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["evaluation_counts"], dict(pending=1, completed=0)) + + def test_criteria_revision_conflict_and_deadline(self): + response = self.rate(expected_criteria_revision="0" * 64) + self.assert_error(response, "criteria_changed", 409) + self.assertEqual( + response.data["meta"]["current_criteria_revision"], revision(self.program) + ) + self.program.datetime_evaluation_ends = timezone.now() - timezone.timedelta( + seconds=1 + ) + self.program.save() + self.assert_error(self.rate(), "evaluation_closed", 409) + frame = call(self.expert, "get", self.evaluation_url) + self.assertFalse(frame.data["can_rate"]) + self.assertFalse(SolutionScore.objects.exists()) + + def test_roles_scope_unassigned_hidden_and_team_own_managed_forbidden(self): + self.assertEqual(call(self.second, "get", self.evaluation_url).status_code, 404) + self.assertEqual(self.rate(self.second).status_code, 404) + self.assert_error( + call(self.manager, "get", self.evaluation_url), "not_program_expert", 403 + ) + self.assertEqual( + call(create_rate_expert(), "get", self.evaluation_url).status_code, 404 + ) + self.assert_error( + call(self.captain, "get", self.list_url), "not_program_expert", 403 + ) + owner_expert, _ = Expert.objects.get_or_create(user=self.captain) + self.captain.is_active = True + self.captain.save(update_fields=["is_active"]) + owner_expert.programs.add(self.program) + self.program.is_distributed_evaluation = False + self.program.save() + self.assert_error(self.rate(self.captain), "own_team", 403) + draft = self.another_solution(submitted=False) + self.assertEqual( + call( + self.expert, + "get", + f"/programs/{self.program.pk}/solutions/{draft.pk}/evaluation/", + ).status_code, + 404, + ) + + def test_list_filters_before_pagination_status_own_completion_and_invalid_case(self): + self.another_solution(submitted=False) + response = call(self.expert, "get", self.list_url + "?limit=1&status=pending") + self.assertEqual(response.status_code, 200, response.data) + self.assert_schema("RatingPage", response.data) + self.assertEqual(response.data["count"], 1) + self.assertEqual( + response.data["results"][0]["application"]["solution_id"], self.solution.pk + ) + self.assertEqual(call(self.second, "get", self.list_url).data["count"], 0) + self.rate() + self.assertEqual( + call(self.expert, "get", self.list_url + "?status=pending").data["count"], 0 + ) + self.assertEqual( + call(self.expert, "get", self.list_url + "?status=completed").data["count"], 1 + ) + self.assertEqual( + call(self.expert, "get", self.list_url + "?search=nomatch").data["count"], 0 + ) + self.assert_error( + call( + self.expert, + "get", + self.list_url + "?case_scope=selected&case_name=foreign", + ), + "invalid_filter", + 400, + ) + + def test_legacy_open_partial_limit_and_existing_rater_can_edit(self): + self.program.required_evaluators = None + self.program.is_distributed_evaluation = False + self.program.max_project_rates = 1 + self.program.save() + rows = [dict(criterion_id=self.criteria[0].pk, value="3")] + first = self.rate(scores=rows) + self.assertEqual(first.status_code, 200, first.data) + self.assertFalse(first.data["is_completed"]) + self.assertEqual(first.data["rated_count"], 1) + self.assert_error(self.rate(self.second, scores=rows), "max_rates_reached", 409) + self.assertEqual(self.rate(scores=rows).status_code, 200) + + def test_assignment_revision_permissions_foreign_and_draft_are_atomic(self): + options = self.options() + self.assert_schema("AssignmentOptions", options) + self.assertEqual(options["expert_id"], self.expert.expert.pk) + self.assertEqual(call(self.expert, "get", self.options_url()).status_code, 403) + self.assert_error(self.assign([], revision="0" * 64), "stale_assignments", 409) + draft = self.another_solution(submitted=False) + self.assert_error(self.assign([draft.pk]), "invalid_assignments", 400) + self.assert_error(self.assign([2**53 - 1]), "invalid_assignments", 400) + self.assert_error( + self.assign([self.solution.pk, self.solution.pk]), "invalid_assignments", 400 + ) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_assignment_quota_scope_remove_and_protect_partial_scores(self): + self.assert_error( + self.assign([self.solution.pk], self.second), "expert_limit_reached", 409 + ) + self.assertEqual(self.assign([]).status_code, 200) + scope = ExpertCaseScope.objects.create( + partner_program=self.program, + expert=self.second.expert, + all_cases=False, + case_names=[], + ) + self.assert_error( + self.assign([self.solution.pk], self.second), "invalid_assignments", 400 + ) + scope.case_names = ["Задача 1"] + scope.save() + self.assertEqual(self.assign([self.solution.pk], self.second).status_code, 200) + SolutionScore.objects.create( + solution=self.solution, user=self.second, criteria=self.criteria[0], value="5" + ) + self.assert_error(self.assign([], self.second), "scores_protected", 409) + with self.assertRaises(ValidationError): + SolutionExpertAssignment.objects.filter(expert=self.second.expert).delete() + self.assertTrue(self.options(self.second)["solutions"][0]["protected"]) + scope.case_names = [] + scope.save() + self.assertEqual(call(self.second, "get", self.evaluation_url).status_code, 200) + + def test_explicit_assignment_opt_in_preserves_historical_scores(self): + self.assignment.delete() + self.program.required_evaluators = None + self.program.is_distributed_evaluation = False + self.program.max_project_rates = 2 + self.program.save() + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=self.criteria[0], value="4" + ) + self.assert_error(self.assign([]), "scores_protected", 409) + self.program.refresh_from_db() + self.assertIsNone(self.program.required_evaluators) + response = self.assign([self.solution.pk]) + self.assertEqual(response.status_code, 200, response.data) + self.program.refresh_from_db() + self.assertEqual(self.program.required_evaluators, 2) + self.assertTrue(self.program.is_distributed_evaluation) + self.assertEqual(SolutionScore.objects.count(), 1) + self.assertEqual(SolutionExpertAssignment.objects.count(), 1) + + def test_model_guards_program_draft_own_and_existing_assignment_identity(self): + foreign = create_rate_criteria(self.another_solution(submitted=False).program) + foreign_program = create_partner_program() + foreign.partner_program = foreign_program + foreign.save() + with self.assertRaises(ValidationError): + SolutionScore.objects.create( + solution=self.solution, user=self.expert, criteria=foreign, value="1" + ) + self.assignment.expert = self.second.expert + with self.assertRaises(ValidationError): + self.assignment.save() + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionEvaluationRaceTests(EvaluationFixture, TransactionTestCase): + def setUp(self): + from unittest.mock import patch + + delivery = patch("project_rates.solution_notifications.send_email.delay") + delivery.start() + self.addCleanup(delivery.stop) + self.seed_evaluation() + + def race(self, *actions): + barrier = Barrier(len(actions)) + + def run(action): + close_old_connections() + try: + barrier.wait(timeout=15) + action() + return "ok" + except SolutionError as error: + return error.payload["code"] + except Http404: + return "not_found" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=len(actions)) as executor: + return list(executor.map(run, actions)) + + @skipUnlessDBFeature("has_select_for_update") + def test_concurrent_open_scores_enforce_last_rater_slot(self): + self.program.required_evaluators = None + self.program.is_distributed_evaluation = False + self.program.max_project_rates = 1 + self.program.save() + + def action(actor): + return lambda: solution_evaluation.rate_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=actor, + **self.scores(), + ) + + self.assertCountEqual( + self.race(action(self.expert), action(self.second)), + ["ok", "max_rates_reached"], + ) + self.assertEqual(SolutionScore.objects.values("user_id").distinct().count(), 1) + + @skipUnlessDBFeature("has_select_for_update") + def test_score_and_revoke_share_program_lock_without_losing_saved_scores(self): + expected = self.options()["revision"] + result = self.race( + lambda: solution_evaluation.rate_solution( + program_id=self.program.pk, + solution_id=self.solution.pk, + actor=self.expert, + **self.scores(), + ), + lambda: solution_assignments.save_assignments( + program_id=self.program.pk, + actor=self.manager, + expert_id=self.expert.expert.pk, + expected_revision=expected, + solution_ids=[], + ), + ) + self.assertIn("ok", result) + self.assertTrue( + "stale_assignments" in result + or "scores_protected" in result + or "not_found" in result + ) + if SolutionScore.objects.exists(): + self.assertTrue( + SolutionExpertAssignment.objects.filter( + solution=self.solution, expert=self.expert.expert + ).exists() + ) diff --git a/project_rates/tests/test_solution_results.py b/project_rates/tests/test_solution_results.py new file mode 100644 index 00000000..070d6903 --- /dev/null +++ b/project_rates/tests/test_solution_results.py @@ -0,0 +1,192 @@ +from types import SimpleNamespace + +from django.test import SimpleTestCase, TestCase, override_settings + +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) +from partner_programs.services.results import results_data as project_results +from project_rates.evaluation_math import evaluation_totals, assign_places +from project_rates.models import Criteria, ProjectScore +from project_rates.solution_models import SolutionScore +from project_rates.solution_results import results_data as solution_results +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_project, + link_project_to_program, +) +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +class EvaluationMathTests(SimpleTestCase): + def criteria(self): + return [ + SimpleNamespace(pk=pk, type=kind, min_value=minimum, max_value=maximum) + for pk, kind, minimum, maximum in [ + (1, "int", 0, 10), + (2, "float", 0, 20), + (3, "bool", None, None), + (4, "str", None, None), + ] + ] + + def test_half_up_rounding_complete_only_zero_false_and_comment_null(self): + result = evaluation_totals( + self.criteria(), + { + 1: {1: "0", 2: "1.005", 3: "False", 4: ""}, + 2: {1: "0", 2: "1.005", 3: "True", 4: None}, + 3: {1: "10", 2: "20", 3: "False"}, + }, + ) + self.assertEqual(result["completed"], 2) + self.assertEqual(result["score"], 1.01) + self.assertEqual(result["averages"], {"1": 0.0, "2": 1.01}) + self.assertEqual(result["maximum"], 30) + self.assertFalse(result["evaluations"][3]["complete"]) + + def test_invalid_numeric_bool_range_and_partial_values_never_enter_average(self): + for values in [ + {1: "NaN", 2: "1", 3: "True", 4: ""}, + {1: "1", 2: "Infinity", 3: "True", 4: ""}, + {1: "1.5", 2: "1", 3: "True", 4: ""}, + {1: "11", 2: "1", 3: "True", 4: ""}, + {1: "1", 2: "1", 3: "false", 4: ""}, + {}, + ]: + with self.subTest(values=values): + result = evaluation_totals(self.criteria(), {1: values}) + self.assertEqual(result["completed"], 0) + self.assertIsNone(result["score"]) + self.assertEqual(result["averages"], {}) + + def test_no_criteria_or_no_numeric_criteria_have_no_rankable_score(self): + result = evaluation_totals([], {1: {}}) + self.assertEqual(result["completed"], 0) + result = evaluation_totals(self.criteria()[2:], {1: {3: "False", 4: ""}}) + self.assertEqual(result["completed"], 1) + self.assertIsNone(result["score"]) + self.assertFalse(result["has_numeric_criteria"]) + definitions = self.criteria() + definitions[0].max_value = None + self.assertIsNone(evaluation_totals(definitions, {})["maximum"]) + + def test_ties_use_competition_places_and_incomplete_rows_are_unranked(self): + rows = [ + dict(solution_id=i, name=str(i), score=value, ready=ready, place=None) + for i, value, ready in [ + (5, 5, True), + (3, 3, True), + (4, 5, True), + (1, 9, False), + ] + ] + assign_places(rows, "solution_id") + self.assertEqual( + {row["solution_id"]: row["place"] for row in rows}, + {5: 1, 3: 3, 4: 1, 1: None}, + ) + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionResultsParityTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + + def test_actual_project_and_solution_values_produce_identical_results(self): + legacy = create_rate_program(is_competitive=True, required_evaluators=2) + legacy.criterias.all().delete() + old_criteria = [ + Criteria.objects.create( + partner_program=legacy, + name=criterion.name, + type=criterion.type, + min_value=criterion.min_value, + max_value=criterion.max_value, + ) + for criterion in self.criteria + ] + field = PartnerProgramField.objects.create( + partner_program=legacy, + name="case", + field_type="select", + options="Задача 1", + label="Кейс", + is_required=True, + show_filter=True, + ) + project = create_rate_project(name=self.solution.name) + project.name = self.solution.name + project.save() + legacy.users.add(project.leader, through_defaults={"partner_program_data": {}}) + link = link_project_to_program(legacy, project) + PartnerProgramFieldValue.objects.create( + program_project=link, field=field, value_text="Задача 1" + ) + PartnerProgramProject.objects.filter(pk=link.pk).update(submitted=True) + self.program.required_evaluators = 2 + self.program.save() + for user, number in [(self.expert, "0"), (self.second, "9")]: + for old, current, value in zip( + old_criteria, self.criteria, [number, "False", ""] + ): + ProjectScore.objects.create( + project=project, user=user, criteria=old, value=value + ) + SolutionScore.objects.create( + solution=self.solution, user=user, criteria=current, value=value + ) + query = dict(search="", case_scope="all", scope="all", order="desc", status="all") + before = project_results(legacy, query) + after = solution_results(self.program, query) + old_row = before["groups"][0]["projects"][0] + row = after["groups"][0]["solutions"][0] + for key in [ + "name", + "case_name", + "completed", + "required", + "evaluation_complete", + "ready", + "score", + "place", + ]: + self.assertEqual(row[key], old_row[key], key) + self.assertEqual( + list(row["averages"].values()), list(old_row["averages"].values()) + ) + self.assertEqual(row["score"], 4.5) + self.assertEqual(row["application"]["solution_id"], self.solution.pk) + self.assertNotIn("project_id", row) + self.assertEqual(after["maximum"], before["maximum"]) + self.assertEqual( + [e["total"] for e in row["experts"]], [e["total"] for e in old_row["experts"]] + ) + + def test_filter_keeps_global_place_scope_all_and_projection_does_not_invent_rank( + self, + ): + self.rate() + query = dict( + search="missing", + case_scope="all", + scope="current", + order="desc", + status="all", + ) + self.assertEqual(solution_results(self.program, query)["groups"], []) + query["scope"] = "all" + row = solution_results(self.program, query)["groups"][0]["solutions"][0] + self.assertEqual(row["place"], 1) + projection = solution_results(self.program, query, solution_id=self.solution.pk) + self.assertIsNone(projection["groups"][0]["solutions"][0]["place"]) + self.another_solution(submitted=False) + self.assertEqual( + sum( + len(group["solutions"]) + for group in solution_results(self.program, query)["groups"] + ), + 1, + ) diff --git a/project_rates/tests/test_solution_results_export.py b/project_rates/tests/test_solution_results_export.py new file mode 100644 index 00000000..36d58e5c --- /dev/null +++ b/project_rates/tests/test_solution_results_export.py @@ -0,0 +1,178 @@ +from io import BytesIO +from urllib.parse import urlencode + +from django.test import TestCase, override_settings +from openpyxl import load_workbook + +from partner_programs.services.results_excel import export_solution_results +from partner_programs.tests.helpers import create_user +from partner_programs.tests.test_solution_forms import call +from project_rates.models import Criteria +from project_rates.solution_models import SolutionScore +from project_rates.solution_results import results_data +from project_rates.tests.helpers import create_rate_program +from project_rates.tests.test_solution_evaluation import EvaluationFixture + + +@override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"]) +class SolutionResultsExportTests(EvaluationFixture, TestCase): + def setUp(self): + self.seed_evaluation() + self.export_url = ( + f"/programs/{self.program.pk}/management/solution-results/export/" + ) + self.results_url = f"/programs/{self.program.pk}/management/solution-results/" + + def download(self, **query): + response = call(self.manager, "get", self.export_url + "?" + urlencode(query)) + self.assertEqual(response.status_code, 200, getattr(response, "data", None)) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.assertEqual(response["Referrer-Policy"], "no-referrer") + return response, load_workbook(BytesIO(response.content), data_only=False) + + def test_real_ids_json_parity_numeric_zero_false_and_formula_types(self): + self.rate() + response, book = self.download() + self.assertEqual( + response["Content-Type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + self.assertIn( + f'filename="program-{self.program.pk}-solution-results.xlsx"', + response["Content-Disposition"], + ) + self.assertEqual(book.sheetnames, ["Рейтинг", "Оценки экспертов"]) + rating, experts = book.worksheets + payload = call(self.manager, "get", self.results_url).data + row = payload["groups"][0]["solutions"][0] + self.assertEqual(rating["B1"].value, "Решение") + self.assertEqual(rating["I1"].value, "ID решения") + self.assertEqual(rating["A2"].value, row["place"]) + self.assertEqual(rating["B2"].value, row["name"]) + self.assertEqual(rating["D2"].value, row["completed"]) + self.assertEqual(rating["E2"].value, row["required"]) + self.assertEqual(rating["I2"].value, self.solution.pk) + self.assertEqual(experts["J1"].value, "ID решения") + self.assertEqual(experts["J2"].value, self.solution.pk) + self.assertEqual(experts["K2"].value, self.expert.pk) + self.assertEqual(experts["F2"].value, 0) + self.assertEqual(experts["F2"].data_type, "n") + self.assertEqual(experts["H2"].value, "Нет") + self.assertEqual(experts["G2"].value, "=SUM(F2:F2)") + self.assertEqual(experts["G2"].data_type, "f") + self.assertEqual( + rating["G2"].value, + "=ROUND(AVERAGEIF('Оценки экспертов'!$E$2:$E$2,\"Полная\"," + "'Оценки экспертов'!G2:G2),2)", + ) + self.assertTrue(book.calculation.fullCalcOnLoad) + self.assertEqual(row["score"], 0) + self.assertNotIn("ID проекта", [c.value for c in rating[1]]) + + def test_all_scope_filters_and_drafts_have_same_selection_as_results(self): + self.rate() + second = self.another_solution() + draft = self.another_solution(submitted=False) + _, all_book = self.download(scope="all", search="missing", status="pending") + self.assertEqual( + {row[8] for row in all_book["Рейтинг"].values if isinstance(row[8], int)}, + {self.solution.pk, second.pk}, + ) + self.assertNotIn(draft.pk, [cell.value for cell in all_book["Рейтинг"]["I"][1:]]) + _, missing = self.download(search="missing") + self.assertEqual(missing["Рейтинг"].max_row, 1) + _, filtered = self.download(search="Вторая", status="pending") + self.assertEqual(filtered["Рейтинг"].max_row, 2) + self.assertEqual(filtered["Рейтинг"]["I2"].value, second.pk) + _, complete = self.download( + status="complete", case_scope="selected", case_name="Задача 1" + ) + self.assertEqual(complete["Рейтинг"].max_row, 2) + self.assertEqual(complete["Рейтинг"]["I2"].value, self.solution.pk) + _, without = self.download(case_scope="without_case") + self.assertEqual(without["Рейтинг"].max_row, 1) + + def test_partial_expert_remains_in_book_but_not_in_average_formula(self): + self.rate() + SolutionScore.objects.create( + solution=self.solution, + user=self.second, + criteria=self.criteria[0], + value="10", + ) + _, book = self.download() + experts = book["Оценки экспертов"] + self.assertEqual(experts["E2"].value, "Полная") + self.assertEqual(experts["E3"].value, "Неполная") + self.assertIsNone(experts["G3"].value) + self.assertIn("AVERAGEIF(", book["Рейтинг"]["G2"].value) + self.assertIn('"Полная"', book["Рейтинг"]["G2"].value) + self.assertEqual(book["Рейтинг"]["D2"].value, 1) + + def test_user_text_never_becomes_formula_and_writer_does_not_mutate_data(self): + self.rate() + query = dict(search="", case_scope="all", scope="all", order="desc", status="all") + data = results_data(self.program, query) + row = data["groups"][0]["solutions"][0] + row["name"] = '=HYPERLINK("https://example.test","text")' + row["experts"][0]["values"][-1]["value"] = "=1+1" + book = load_workbook(BytesIO(export_solution_results(data))) + self.assertEqual(book["Рейтинг"]["B2"].value, row["name"]) + self.assertEqual(book["Рейтинг"]["B2"].data_type, "s") + self.assertEqual(book["Оценки экспертов"]["I2"].value, "=1+1") + self.assertEqual(book["Оценки экспертов"]["I2"].data_type, "s") + self.assertNotIn("project_id", row) + self.assertNotIn("projects", data["groups"][0]) + + def test_empty_and_non_numeric_results_are_valid_without_invented_place(self): + _, empty = self.download(search="missing") + self.assertEqual([sheet.max_row for sheet in empty.worksheets], [1, 1]) + Criteria.objects.filter(pk=self.criteria[0].pk).delete() + self.rate(scores=[dict(criterion_id=self.criteria[1].pk, value="False")]) + _, book = self.download() + self.assertIsNone(book["Рейтинг"]["A2"].value) + self.assertIsNone(book["Рейтинг"]["F2"].value) + self.assertEqual(book["Рейтинг"]["G2"].value, "Оценивание завершено") + + def test_roles_other_program_and_legacy_mode_cannot_download_closed_results(self): + for actor in (None, self.captain, self.expert, create_user()): + with self.subTest(actor=getattr(actor, "pk", None)): + response = call(actor, "get", self.export_url) + self.assertIn(response.status_code, (401, 403)) + self.assert_schema("Error", response.data) + foreign = create_rate_program() + response = call( + self.manager, + "get", + f"/programs/{foreign.pk}/management/solution-results/export/", + ) + self.assertEqual(response.status_code, 403) + foreign.managers.add(self.manager) + response = call( + self.manager, + "get", + f"/programs/{foreign.pk}/management/solution-results/export/", + ) + self.assertEqual(response.status_code, 409) + self.assert_schema("Error", response.data) + response = call( + self.manager, + "get", + "/programs/9007199254740991/management/solution-results/export/", + ) + self.assertEqual(response.status_code, 404) + + def test_invalid_query_uses_c00_json_errors_including_unknown_keys(self): + for query in ( + dict(scope="wrong"), + dict(project_id=1), + dict(case_scope="selected", case_name="Чужой кейс"), + dict(scope="all", case_scope="selected", case_name="Чужой кейс"), + ): + with self.subTest(query=query): + response = call( + self.manager, "get", self.export_url + "?" + urlencode(query) + ) + self.assertEqual(response.status_code, 400, response.data) + self.assert_schema("Error", response.data) + self.assertIn("application/json", response["Content-Type"]) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index faf35ff4..591aaea5 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -67,6 +67,10 @@ def assignment_snapshot(program): def revision(program): """Includes team, membership, submission and score changes invalidating a plan.""" + if program.solution_storage_mode == "solution": + from project_rates.solution_assignments import revision as solution_revision + + return solution_revision(program) data = [ program.pk, program.required_evaluators, @@ -145,6 +149,12 @@ def check_assignment_limit(program, additions, removals=()): def enable_workspace(program): """Explicit organizer action opts in; preserve assignments behind historical scores.""" + if program.solution_storage_mode == "solution": + from project_rates.solution_assignments import ( + enable_workspace as enable_solution_workspace, + ) + + return enable_solution_workspace(program) if program.required_evaluators is not None: return program.required_evaluators = target(program) @@ -631,10 +641,23 @@ def my_programs(user): ) rows = [] for program in programs: - assignments = list( - annotated_assignment_queryset(program.pk).filter(expert__user=user) - ) - done = sum(a.is_completed for a in assignments) + if program.solution_storage_mode == "solution": + from project_rates.solution_allocation import ( + assignment_snapshot as solution_assignments, + ) + + expert_id = program.experts.get(user=user).pk + assignments = [ + row + for row in solution_assignments(program, include_historical=False) + if row["expert_id"] == expert_id + ] + done = sum(row["is_completed"] for row in assignments) + else: + assignments = list( + annotated_assignment_queryset(program.pk).filter(expert__user=user) + ) + done = sum(a.is_completed for a in assignments) rows.append( { **program_card(program), diff --git a/project_rates/workspace_urls.py b/project_rates/workspace_urls.py index 4f2db0ef..4553f13e 100644 --- a/project_rates/workspace_urls.py +++ b/project_rates/workspace_urls.py @@ -1,4 +1,5 @@ from django.urls import path +from .solution_cabinet_api import CabinetApplicationsView from .notifications import ExpertNotificationsView from .workspace_views import ( MyProgramsView, @@ -8,6 +9,7 @@ ) urlpatterns = [ + path("applications/", CabinetApplicationsView.as_view()), path("notifications/", ExpertNotificationsView.as_view()), path("notifications//read/", ExpertNotificationsView.as_view()), path("programs/", MyProgramsView.as_view()), diff --git a/projects/team_policy.py b/projects/team_policy.py index 5e182128..0396490a 100644 --- a/projects/team_policy.py +++ b/projects/team_policy.py @@ -114,22 +114,44 @@ def has_active_exclusivity(program): return program.is_competitive and program.datetime_finished > timezone.now() -def conflicting_team_users(program, *, project_id, user_ids): +def conflicting_team_users(program, *, project_id, user_ids, solution_id=None): if not has_active_exclusivity(program): return set() other_projects = Project.objects.filter( program_links__partner_program=program ).exclude(pk=project_id) - return set( - other_projects.filter(leader_id__in=user_ids).values_list("leader_id", flat=True) - ) | set( - Collaborator.objects.filter( - project__in=other_projects, user_id__in=user_ids - ).values_list("user_id", flat=True) + from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember + + other_solutions = ProgramSolution.objects.filter(program=program).exclude( + pk=solution_id + ) + return ( + set( + other_projects.filter(leader_id__in=user_ids).values_list( + "leader_id", flat=True + ) + ) + | set( + Collaborator.objects.filter( + project__in=other_projects, user_id__in=user_ids + ).values_list("user_id", flat=True) + ) + | set( + other_solutions.filter(captain_id__in=user_ids).values_list( + "captain_id", flat=True + ) + ) + | set( + ProgramSolutionMember.objects.filter( + solution__in=other_solutions, user_id__in=user_ids + ).values_list("user_id", flat=True) + ) ) -def validate_program_users(program, *, project_id, user_ids, lock=False): +def validate_program_users( + program, *, project_id, user_ids, lock=False, solution_id=None +): memberships = PartnerProgramUserProfile.objects.filter( partner_program=program, user_id__in=user_ids ) @@ -141,7 +163,9 @@ def validate_program_users(program, *, project_id, user_ids, lock=False): "not_program_member", "Каждый участник команды должен быть зарегистрирован в программе.", ) - if conflicting_team_users(program, project_id=project_id, user_ids=user_ids): + if conflicting_team_users( + program, project_id=project_id, user_ids=user_ids, solution_id=solution_id + ): raise TeamError( "already_in_program_team", "Пользователь уже состоит в другой команде этой программы.", @@ -245,6 +269,12 @@ def actionable_users(project, users, *, program_link_id=None): for program in context_for(project).programs.values(): users = users.filter(partner_program_profiles__partner_program=program) if has_active_exclusivity(program): + from partner_programs.solution_models import ( + ProgramSolution, + ProgramSolutionMember, + ) + + solutions = ProgramSolution.objects.filter(program=program) others = Project.objects.filter( program_links__partner_program=program ).exclude(pk=project.pk) @@ -255,6 +285,12 @@ def actionable_users(project, users, *, program_link_id=None): "user_id" ) ) + | Q(pk__in=solutions.values("captain_id")) + | Q( + pk__in=ProgramSolutionMember.objects.filter( + solution__in=solutions + ).values("user_id") + ) ) return users.distinct() diff --git a/vacancy/mapping.py b/vacancy/mapping.py index 1f58edca..a1f72c30 100644 --- a/vacancy/mapping.py +++ b/vacancy/mapping.py @@ -2,7 +2,13 @@ from typing import Literal, TypeAlias, TypedDict, Annotated EmailTextTypes: TypeAlias = Literal[ - "accepted", "outdated", "responded", "rejected", "registered_program", "project_rated" + "accepted", + "outdated", + "responded", + "rejected", + "registered_program", + "project_rated", + "solution_rated", ] @@ -15,6 +21,7 @@ class MessageTypeEnum(Enum): REGISTERED_PROGRAM_USER = "registered_program" PROJECT_RATED = "project_rated" + SOLUTION_RATED = "solution_rated" class BaseEmailCeleryParams(TypedDict): @@ -40,8 +47,18 @@ class ProjectRatedParams(BaseEmailCeleryParams): project_id: int +class SolutionRatedParams(BaseEmailCeleryParams): + program_name: str + program_id: int + solution_name: str + solution_id: int + + EmailParamsType: TypeAlias = ( - CeleryEmailParams | ProjectRatedParams | UserProgramRegisterParams + CeleryEmailParams + | ProjectRatedParams + | UserProgramRegisterParams + | SolutionRatedParams ) message_type_to_button_text: dict[Annotated[str, EmailTextTypes], str] = { @@ -51,6 +68,7 @@ class ProjectRatedParams(BaseEmailCeleryParams): MessageTypeEnum.REJECTED.value: "Посмотреть на проект", MessageTypeEnum.REGISTERED_PROGRAM_USER.value: "Посмотреть на программу", MessageTypeEnum.PROJECT_RATED.value: "Посмотреть на проект", + MessageTypeEnum.SOLUTION_RATED.value: "Открыть решение", } message_type_to_title: dict[Annotated[str, EmailTextTypes], str] = { @@ -60,6 +78,7 @@ class ProjectRatedParams(BaseEmailCeleryParams): MessageTypeEnum.REJECTED.value: "На ваш отклик ответили отказом", MessageTypeEnum.REGISTERED_PROGRAM_USER.value: "Вы зарегистрировались на программу", MessageTypeEnum.PROJECT_RATED.value: "Ваш проект был оценен", + MessageTypeEnum.SOLUTION_RATED.value: "Ваше решение оценено", } @@ -81,6 +100,11 @@ def get_link(data: EmailParamsType): case MessageTypeEnum.PROJECT_RATED.value: return f"https://app.procollab.ru/office/projects/{data['project_id']}" + case MessageTypeEnum.SOLUTION_RATED.value: + from django.conf import settings + + base = settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + return f"{base}/office/program/{data['program_id']}/solutions/{data['solution_id']}" def create_text_for_email(data: EmailParamsType) -> str: @@ -111,3 +135,8 @@ def create_text_for_email(data: EmailParamsType) -> str: Ваш проект '{data["project_name"]}' был оценен экспертом по программе '{data["program_name"]}'! Скоро будут результаты. """ + case MessageTypeEnum.SOLUTION_RATED.value: + return ( + f"Ваше решение '{data['solution_name']}' оценено экспертом программы " + f"'{data['program_name']}'. Скоро будут результаты." + )