From 19521a37ab7bdaf5285a8a2902b50655db7fc448 Mon Sep 17 00:00:00 2001 From: Fedor Date: Tue, 21 Jul 2026 16:51:02 +0300 Subject: [PATCH] Add application team domain service --- docs/application-team-model.md | 22 +- docs/application-team-service.md | 134 ++++ docs/case-championship-domain.md | 74 +- docs/program-participation-policy.md | 31 +- partner_programs/applications_views.py | 115 +-- partner_programs/serializers/applications.py | 16 + partner_programs/services/__init__.py | 40 ++ partner_programs/services/application_team.py | 555 +++++++++++++++ .../tests/test_application_api.py | 115 ++- .../tests/test_application_team_service.py | 659 ++++++++++++++++++ 10 files changed, 1651 insertions(+), 110 deletions(-) create mode 100644 docs/application-team-service.md create mode 100644 partner_programs/services/application_team.py create mode 100644 partner_programs/tests/test_application_team_service.py diff --git a/docs/application-team-model.md b/docs/application-team-model.md index 27021cc4..275a316a 100644 --- a/docs/application-team-model.md +++ b/docs/application-team-model.md @@ -8,9 +8,9 @@ - `Team` — команда, собранная только для этой заявки; - `TeamMember` — членство пользователя в команде заявки. -Публичного Team API и атомарного service создания команды пока нет. Модели -подготавливают структуру данных для следующих PR, не меняя текущий -индивидуальный API flow. +Публичного Team API пока нет. Транзакционный Application/Team service создает +командный draft вместе с Team и accepted-капитаном и проверяет полный invariant +перед submit. Состав команды через публичный API пока не редактируется. ## Participation mode @@ -110,28 +110,26 @@ TeamMember, а TeamMember уже требует сохраненную Team. ## Текущий технический порядок создания -До появления публичного API техническая последовательность выглядит так: +Domain service выполняет последовательность атомарно: 1. создать или обновить Application с `participation_mode=team`; 2. создать Team с captain, совпадающим с `Application.user`; 3. создать accepted TeamMember с `role=captain` и тем же пользователем. -Клиентам нельзя использовать эту последовательность напрямую. Сейчас нет -публичных serializers/views/URLs и транзакционного service, который откатит -частично созданную команду. +Клиенты используют существующий Application create endpoint с +`participation_mode=team` и необязательным `team_name`. При ошибке service +откатывает все три шага. Публичных Team serializers/views/URLs и управления +участниками по-прежнему нет. ## Вне текущего MVP Следующие PR должны добавить: -- атомарный creation/invariant service; -- проверку Registration и одной активной заявки на Program; - Team permissions и публичный Team API; - блокировку состава по Application status; - TeamInvite, accept/decline/revoke/expire; - email и внутренние уведомления; -- program policy по формату и размеру команды; - frontend wizard и вкладку команды. -Project, `projects.Collaborator`, legacy `invites.Invite`, Submission и -существующие Application endpoints этим слоем данных не изменяются. +Legacy Application withdraw, Project/`projects.Collaborator`, +`invites.Invite` и Submission flow этим service не изменяются. diff --git a/docs/application-team-service.md b/docs/application-team-service.md new file mode 100644 index 00000000..07efa7ba --- /dev/null +++ b/docs/application-team-service.md @@ -0,0 +1,134 @@ +# Application Team Service + +## Назначение + +`partner_programs.services.application_team` — транзакционный domain service +для индивидуальных и командных `Application`. Он отделяет бизнес-правила от +DRF views и не зависит от HTTP response. + +Service предоставляет четыре публичные операции: + +- `create_or_get_application()`; +- `change_application_participation_mode()`; +- `validate_team_invariants()`; +- `submit_application()`. + +## Доменные ошибки + +Все ожидаемые отказы наследуются от `ApplicationTeamServiceError` и содержат +стабильные `code`, `detail` и `field`. Views преобразуют их в DRF +`ValidationError`, но сам service не импортирует DRF. + +Основные коды: `registration_required`, `application_deadline_passed`, +`participation_mode_not_allowed`, `participation_mode_undecided`, +`active_application_conflict`, `team_required`, `team_not_allowed`, +`team_has_other_members`, `captain_member_missing`, `captain_mismatch`, +`team_size_invalid`, `team_member_registration_missing` и +`application_not_editable`. + +## Создание Application + +`create_or_get_application()` выполняет в одной транзакции: + +1. блокирует строку `PartnerProgram`; +2. проверяет `PartnerProgramUserProfile` владельца; +3. проверяет `datetime_application_ends` без fallback на legacy-дедлайны; +4. валидирует формат по Program policy; +5. ищет активную собственную Application и accepted-членство в другой Team; +6. создает individual/undecided draft либо team draft; +7. для team атомарно создает `Team` и accepted captain `TeamMember`. + +Повторный совместимый запрос возвращает существующую Application с +`created=False`. Отличающийся формат, form data, Project или Team name не +перезаписывает существующий draft скрытым образом и дает конфликт. + +`undecided` допустим для draft и не создает Team. Старый API-запрос без +`participation_mode` преобразуется view в `individual`, поэтому существующий +frontend сохраняет прежний формат. + +## Смена формата + +`change_application_participation_mode()` доступен только владельцу draft до +application deadline: + +- `undecided → individual` меняет только поле; +- `undecided/individual → team` создает Team и капитана; +- `individual → undecided` разрешен, пока Team отсутствует; +- `team → individual` удаляет Team только при наличии единственной записи + accepted-капитана; +- `team → undecided` запрещен, чтобы не удалять состав неявно; +- повторный `team → team` может изменить `team_name`. + +PATCH Application вызывает эту операцию до сохранения остальных полей в общей +транзакции. Serializer намеренно не записывает `participation_mode` и +`team_name` напрямую. + +## Конфликт активного участия + +Активными остаются `draft`, `submitted` и `approved`. Для пользователя service +учитывает обе роли: + +- `Application.user`; +- accepted `TeamMember` связанной активной Application той же Program. + +Текущая Application исключается при change/submit. Терминальные Application и +членство в другой Program не блокируют новую заявку. + +Строка Program блокируется через `select_for_update()` как общая точка +сериализации, после чего блокируются найденные Application/TeamMember. Это +закрывает гонки между операциями, которые проходят через service. Существующая +partial unique constraint дополнительно защищает две собственные активные +Application. + +Cross-table invariant нельзя выразить обычным UniqueConstraint. Прямые записи +через admin/model и будущий Team API должны использовать тот же service или +отдельную транзакционную операцию принятия участника. + +## Полный Team invariant + +`validate_team_invariants()` ничего не изменяет и проверяет: + +- Team существует только для `participation_mode=team`; +- `Team.application` и капитан согласованы с Application; +- есть ровно один accepted captain member; +- все accepted-участники имеют Registration этой Program; +- accepted-состав находится между `team_min_size` и `team_max_size`. + +Капитан входит в размер команды. `invited`, `declined`, `removed` и `left` +сохраняют историю, но не считаются участниками при submit. + +## Submit + +`submit_application()` сохраняет текущий owner/staff access contract. Staff не +обходит Registration, Program policy, deadline или Team invariant. + +Для draft операция проверяет Registration владельца, application deadline, +финальный формат, cross-table конфликты и Team invariant, затем атомарно +устанавливает `submitted` и `submitted_at`. + +Повторный submit уже отправленной Application идемпотентен: он возвращает +текущую запись, не меняет `submitted_at` и не падает из-за дедлайна, который +истек после первой отправки. + +## API contract + +Существующие routes и throttle scope не изменены: + +- `POST /programs//applications/` принимает необязательные + `participation_mode` и write-only `team_name`; +- отсутствие mode означает `individual`; +- response дополнен `participation_mode`, но не раскрывает TeamMember; +- `PATCH /applications//` проводит mode/team name через service; +- `POST /applications//submit/` вызывает транзакционный submit service. + +Публичного Team CRUD, управления участниками и TeamInvite в этом изменении нет. + +## Ограничения concurrency и MVP + +Production-база с row-level locking сериализует service-операции одной Program. +SQLite не реализует полноценный `select_for_update`, поэтому автоматический +тест проверяет устойчивую последовательную конфликтную операцию без threading. + +Service пока не управляет приглашениями/принятием участников и не блокирует +прямое редактирование моделей через admin. Нет Team permissions для обычных +members, передачи капитанства, returned Application и organizer review. diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index 5987a8e1..8c7e6eed 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -58,8 +58,8 @@ Program **Текущее состояние.** Реализована как `partner_programs.PartnerProgram`. Формат участия, минимальный/максимальный размер команды и отдельный дедлайн -Application хранятся в модели и защищены validation/constraints. Enforcement -в Application/team service и отдельный дедлайн решения пока отсутствуют. +Application хранятся в модели, защищены validation/constraints и применяются +Application/team service. Отдельный дедлайн решения пока отсутствует. ### Registration @@ -115,9 +115,10 @@ collaborators проекта и состояние email-приглашений. только как временный MVP-механизм; предпочтительнее отдельная `TeamInvite`. **Текущее состояние.** Реализована как `partner_programs.Team` с one-to-one -Application, названием, капитаном и timestamps. Публичного Team API и -транзакционного creation service пока нет. `projects.Collaborator` не является -заменой: он связан с долгоживущим `Project`, а не с заявкой на одну программу. +Application, названием, капитаном и timestamps. Транзакционный service создает +Team вместе с accepted-капитаном и проверяет invariant перед submit; публичного +Team API пока нет. `projects.Collaborator` не является заменой: он связан с +долгоживущим `Project`, а не с заявкой на одну программу. ### TeamMember @@ -277,11 +278,10 @@ email-инфраструктура и WebSocket consumer `NotificationConsumer` 6. Пользователь отдельно выбирает «Создать заявку» и вызывает `POST /programs//applications/` при первом сохранении. -Текущая реализация выполняет шаги 1–5. При этом новый Application API пока не -проверяет наличие `PartnerProgramUserProfile` и application-дедлайн. Поэтому -любой авторизованный пользователь технически может создать Application, даже -не зарегистрировавшись в программе, а создание Application не добавляет -программу в `participating=1`. +Текущая реализация выполняет шаги 1–6. Application create требует существующий +`PartnerProgramUserProfile`, проверяет отдельный application-дедлайн и не +создает Registration автоматически. Поэтому источником `participating=1` +по-прежнему остается отдельный registration flow. Регистрационные ответы сейчас находятся в `PartnerProgramUserProfile.partner_program_data`, а ответы новой заявки — в @@ -337,8 +337,9 @@ Application. Возможность создать после них новую `rejected_at` существуют, но новый API их не заполняет. - Staff может читать, submit и withdraw любую Application. Обычный менеджер программы без `is_staff` в новую permission-модель не включен. -- Создание, submit и withdraw не проверяют Registration, дедлайны или правила - формы/команды. +- Создание и submit проверяют Registration, application-дедлайн, Program policy + и cross-table конфликты; submit командной заявки также проверяет полный Team + invariant. Withdraw пока сохраняет прежний contract без этих проверок. ## 6. Формат участия @@ -422,10 +423,11 @@ domain service с блокировкой затрагиваемых строк. простые ограничения `OneToOne(Team.application)`, uniqueness `team + user`, условная уникальность капитана и check `captain → accepted`. -Базовые Team и TeamMember уже реализуют эти простые DB constraints и model -validation. Полный invariant наличия captain member намеренно не проверяется при -первом `Team.save()` из-за циклического порядка создания; до публичного API его -должен обеспечить транзакционный domain service. +Базовые Team и TeamMember реализуют простые DB constraints и model validation. +Полный invariant наличия captain member намеренно не проверяется при первом +`Team.save()` из-за циклического порядка создания. Domain service атомарно +создает капитана и повторно проверяет полный invariant перед submit; будущие +операции управления составом обязаны использовать те же проверки. ## 8. Приглашения @@ -616,24 +618,24 @@ Registration. | Область | Уже реализовано | Частично | Отсутствует | Комментарий | |---|---|---|---|---| -| Registration | `PartnerProgramUserProfile`, register endpoints, `participating=1`, deadline и uniqueness | Статус Registration и связь с новым flow | Отдельная модель/явный contract Registration | Application create не требует Registration | -| Program participation policy | Форматы `individual_only/team_only/individual_or_team`, размеры Team, application deadline, model/DB validation и admin | Helpers пока вызываются только явно | Enforcement в Application/team service и публичный API | Default `individual_only`, размеры и deadline существующих программ остаются `null` | +| Registration | `PartnerProgramUserProfile`, register endpoints, `participating=1`, deadline и uniqueness | Статус Registration и связь с новым flow | Отдельная модель/явный contract Registration | Application create/submit требуют существующий профиль, но не создают его | +| Program participation policy | Форматы `individual_only/team_only/individual_or_team`, размеры Team, application deadline, model/DB validation, admin и service enforcement | Program API намеренно не расширен | Policy UI | Default `individual_only`, размеры и deadline существующих программ остаются `null` | | Application model | Program, user, created_by, participation_mode, form_data, nullable Project, timestamps, partial unique constraint | Ownership по-прежнему опирается на Application.user | returned, snapshot, review reason | Default participation_mode временно individual | -| Application API | create/my/detail/patch/submit/withdraw, idempotency create/submit/withdraw, scoped throttle create | Owner/staff access | review endpoints, manager access, deadline/form/team checks | 404 my application корректно означает отсутствие записи | +| Application API | create/my/detail/patch/submit/withdraw, mode/team service, Registration/deadline/policy/conflict checks, idempotency и scoped throttle | Owner/staff; members без API access | review endpoints и manager/member access | Старый create без mode остается individual; 404 my application означает отсутствие записи | | Application statuses | Шесть статусов и timestamps submit/approve/reject/withdraw | Переходы draft/submit/withdraw | `returned`, return/approve/reject/cancel actions | approved/rejected могут появиться только вне нового API, например через admin | | Submission model | Поля MVP, пять целевых статусов, version/stage constraints, model validation | JSON links вместо отдельной модели | Files, Evaluation, Stage | Program согласуется с Application | | Submission API | list/create/detail/patch/submit/cancel, owner/staff, version allocation, throttling | Только participant/staff flow | manager/expert/team access, return/finalize, deadlines | Создание только для submitted/approved Application | | Project model | Полноценная карточка, лидер, collaborators, links, цели, компании, ресурсы | Lifecycle через `draft/is_public` | Project version/snapshot | Project остается независимым от Application | | Application → Project | Nullable FK, reuse, owner validation, immutable после draft | Ручная связь | Prefill mapping и `project_snapshot` | Автоматически Project не создается | -| Team | One-to-one Application, name, captain, timestamps, model validation, admin | Нет domain service и permissions | Публичный Team API | Состояние редактируемости выводится из Application.status | +| Team | One-to-one Application, name, captain, timestamps, model validation/admin и атомарный creation/invariant service | Нет member permissions | Публичный Team API | Состояние редактируемости выводится из Application.status | | TeamMember | Roles/statuses, invited_by, joined_at, constraints, validation, admin | invited — только модельная заготовка | Team membership API и cross-Application invariants | Не переиспользует Project Collaborator | | TeamInvite | Нет | Project-specific `Invite` | TeamInvite token/email/expiry lifecycle | Текущий Invite имеет только `is_accepted` | | Notification | Email и chat WebSocket infrastructure | Mailing logs не являются inbox | Доменная Notification и пользовательский центр | Не входит в первый Team PR | | Evaluation | `Criteria`, `ProjectScore`, `ProjectExpertAssignment` для legacy Project | Эксперты и распределенное оценивание проекта | Evaluation по Submission | Нельзя смешивать с ProjectScore без миграции | | Result | Legacy scores и пользовательские достижения существуют отдельно | Нет единого результата заявки | Result/ranking/publication contract | Требует решения об источнике итогов | -| Deadlines | Registration, отдельный Application deadline и legacy project submission/evaluation dates | Application helper не подключен к endpoints | Проверки в Application/Submission API и отдельный solution deadline | Application deadline не имеет fallback на legacy-поля | +| Deadlines | Registration, отдельный Application deadline с create/mode/submit enforcement и legacy project submission/evaluation dates | Withdraw/form-only PATCH не используют application deadline | Submission checks и отдельный solution deadline | Application deadline не имеет fallback на legacy-поля | | Permissions | Owner/staff для новых API; manager/expert permissions есть для legacy flow | Staff имеет расширенный доступ | Captain/member, manager и expert permissions для новых сущностей | Нужен общий domain permission layer | -| Constraints | Registration uniqueness; active individual Application; Team one-to-one/member/captain constraints; Submission version; Project collaborators | Captain/member validation есть только внутри одной Team | Cross-team active participation и team size | Cross-table race требует транзакционного service | +| Constraints | Registration uniqueness; active owner Application; Team/Submission DB constraints; service проверяет cross-table участие, Registration и team size | Прямые model/admin записи обходят service | DB constraint для cross-table участия невозможен | Program row lock сериализует service-операции; SQLite test проверяет последовательный конфликт | | Admin | PartnerProgram, Registration, Application, Team, TeamMember, Submission, Project, Invite и legacy evaluation зарегистрированы | Admin позволяет ручную диагностику | TeamInvite/Evaluation admin | Admin не заменяет transition services | | Tests | Model tests Application/Team/TeamMember и подробные API tests Application/Submission; regression tests legacy flow | Нет интеграционного end-to-end Team flow | Invite/evaluation/result tests нового flow | Базовые DB constraints Team покрыты отдельно | @@ -683,10 +685,10 @@ Registration. | Требование | Текущее состояние | Требуемое изменение | Приоритет | Рекомендуемый PR | |---|---|---|---|---| | Явный формат участия | Поле и безопасный default `individual` реализованы | Подключить API/UI и позднее default `undecided` | P0 | Application participation wizard/API | -| Team и TeamMember | Модели, admin, constraints и tests реализованы | Добавить транзакционный service и публичный API | P0 | Application/team invariants | -| Только зарегистрированный создает Application | Не проверяется | Domain service должен проверять PartnerProgramUserProfile | P0 | Application eligibility and lifecycle | -| Одна активная заявка на пользователя с учетом Team | Constraint покрывает только `Application.user` | Транзакционная проверка индивидуальных и командных участий | P0 | Application/team invariants | -| Валидация команды перед submit | Team и Program policy реализованы | Транзакционная проверка accepted-состава и формата перед submit | P0 | Application/team invariants | +| Team и TeamMember | Модели, admin, constraints и транзакционный service реализованы | Добавить permissions и публичный API | P0 | Team permissions/API | +| Только зарегистрированный создает Application | Проверяется create/submit service | Поддержать те же правила в будущих organizer actions | P1 | Application review API | +| Одна активная заявка на пользователя с учетом Team | Service проверяет owner и accepted membership под Program lock | Все будущие Team member actions обязаны использовать проверку | P0 | Team permissions/API | +| Валидация команды перед submit | Проверяются captain, Registration, accepted-состав и Program size | Добавить form-schema validation | P1 | Application form validation | | Captain-only actions | Captain хранится, endpoints Team не учитывают | Team-aware permissions для Application и Submission | P0 | Team permissions | | Team API | Отсутствует | CRUD ограниченного MVP, members, leave/transfer captain | P1 | Team API | | Полный lifecycle Application | Нет returned/review actions | Добавить return/approve/reject/cancel с reason/audit | P1 | Application review API | @@ -710,8 +712,9 @@ Registration. 2. **`feature/program-participation-policy` — реализовано.** Добавлены разрешенные форматы, минимальный/максимальный размер команды и отдельный дедлайн Application; enforcement в API намеренно не включен. -3. **Application/team invariants.** Ввести транзакционный service для проверки - Registration, конфликтов активного участия, капитана и accepted-состава. +3. **`feature/application-team-service` — реализовано.** Транзакционный service + проверяет Registration, deadline, Program policy, конфликты активного + участия, капитана и accepted-состав при create/mode change/submit. 4. **Team permissions and API.** Добавить owner/captain/member/manager access, чтение команды, изменение названия, управление участниками и передачу капитанства без приглашений по email/ссылке. @@ -745,8 +748,9 @@ endpoints без отдельной миграционной задачи. Ветка: `feature/application-team-model`. -Статус: базовый слой данных реализован; serializers, публичный API и -транзакционный creation/invariant service намеренно отсутствуют. +Статус исторического PR: базовый слой данных реализован без service. В +последующем PR транзакционный creation/invariant service подключен к +Application create/PATCH/submit; отдельный Team API по-прежнему отсутствует. ### Точный scope @@ -777,8 +781,8 @@ endpoints без отдельной миграционной задачи. - `individual` Application не может иметь Team. 9. Не требовать наличия captain TeamMember непосредственно при первом `Team.save()`: это создает циклическую последовательность создания. Полный - invariant проверять после атомарного создания Team + captain member в - будущем domain service и обязательно перед submit Application. + invariant теперь проверяется после атомарного создания Team + captain member + в domain service и обязательно перед submit Application. 10. Зарегистрировать Team и TeamMember в Django admin с фильтрами по Program, статусу и роли. 11. Добавлены model tests для choices, one-to-one, uniqueness и validation. @@ -801,6 +805,6 @@ endpoints без отдельной миграционной задачи. legacy project submission; - deploy, settings, Docker, nginx и workflows. -Такой scope дает самостоятельный, мигрируемый слой данных без преждевременного -публичного API. Следующий PR обязан добавить транзакционный creation/invariant -service до того, как Team станет доступна клиентам. +Такой scope дал самостоятельный мигрируемый слой данных. Последующий domain +service уже обеспечивает атомарное создание и submit-invariant до появления +публичного Team API. diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md index 6b8eac6a..c9262102 100644 --- a/docs/program-participation-policy.md +++ b/docs/program-participation-policy.md @@ -4,12 +4,12 @@ Policy хранится в `PartnerProgram` и задает допустимый формат участия, границы размера команды и отдельный дедлайн подачи `Application`. Эти данные -станут источником истины для будущего транзакционного сервиса создания и -отправки заявок. +служат источником истины для транзакционного сервиса создания и отправки +заявок. -В текущем изменении policy валидируется на уровне модели и базы данных, но еще -не применяется существующими Application endpoints. Публичные Program -serializers также намеренно не расширены. +Policy валидируется на уровне модели и базы данных и применяется +Application/Team service при create, смене формата и submit. Публичные Program +serializers при этом намеренно не расширены. ## Формат участия @@ -43,8 +43,8 @@ Runtime default — `individual_only`. Он сохраняет поведени `accepted`. Капитан имеет accepted-запись `TeamMember` и входит в размер команды. Фактическое число участников нельзя надежно проверить constraint-ом одной таблицы: оно зависит от связанных строк и конкурентных изменений. -Поэтому эту проверку должен выполнять будущий транзакционный domain service -непосредственно перед submit Application. +Поэтому эту проверку выполняет транзакционный domain service непосредственно +перед submit Application. ## Дедлайн Application @@ -57,8 +57,8 @@ Runtime default — `individual_only`. Он сохраняет поведени Значение `null` означает, что отдельный дедлайн Application не настроен. Метод `is_application_deadline_passed(at=None)` в таком случае возвращает `False`. -Fallback на регистрацию или сдачу проекта намеренно отсутствует: будущий -domain service должен явно определить поведение при `null`. +Fallback на регистрацию или сдачу проекта намеренно отсутствует. Domain +service трактует `null` как отсутствие блокирующего дедлайна. ## Validation и database constraints @@ -78,11 +78,10 @@ Constraints защищают данные при обходе model validation, ## Текущие ограничения и следующий шаг -Существующие Application API, Team API и legacy registration/project flow не -изменены. Policy пока не блокирует create/edit/submit Application, не проверяет -Registration и не разрешает конфликты участия между командами. +Application create, смена participation mode и submit теперь применяют policy, +проверяют Registration, deadline и конфликты участия. Legacy +registration/project flow не изменен. -Следующий PR должен добавить транзакционный Application/team service. Перед -submit он использует policy для проверки Registration, финального -`participation_mode`, accepted-состава Team и дедлайна без изменения legacy -дедлайнов. +Публичного Team API пока нет. Следующий PR должен добавить Team permissions и +управление составом так, чтобы операции принятия участников использовали те же +cross-table проверки без изменения legacy-дедлайнов. diff --git a/partner_programs/applications_views.py b/partner_programs/applications_views.py index 040525cb..89f915fb 100644 --- a/partner_programs/applications_views.py +++ b/partner_programs/applications_views.py @@ -1,5 +1,5 @@ from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import IntegrityError, transaction +from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status @@ -11,6 +11,13 @@ from core.throttling import PostOnlyScopedRateThrottle from partner_programs.models import Application, PartnerProgram from partner_programs.serializers import ApplicationSerializer +from partner_programs.services.application_team import ( + ApplicationNotEditableError, + ApplicationTeamServiceError, + change_application_participation_mode, + create_or_get_application, + submit_application, +) def _application_queryset_for(user): @@ -48,6 +55,10 @@ def _application_response(application, request, response_status=status.HTTP_200_ return Response(serializer.data, status=response_status) +def _raise_domain_validation_error(exc: ApplicationTeamServiceError): + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + class ProgramApplicationCreateView(APIView): permission_classes = [IsAuthenticated] throttle_classes = [PostOnlyScopedRateThrottle] @@ -60,43 +71,35 @@ def post(self, request, program_id): context={"request": request}, ) serializer.is_valid(raise_exception=True) - - existing_application = _active_application( - program=program, - user=request.user, + validated_data = dict(serializer.validated_data) + # Старый frontend не передает формат: до появления wizard такой запрос + # остается индивидуальным и сохраняет прежний create contract. + participation_mode = validated_data.pop( + "participation_mode", + Application.PARTICIPATION_MODE_INDIVIDUAL, ) - if existing_application: - return _application_response(existing_application, request) - + team_name = validated_data.pop("team_name", None) try: - with transaction.atomic(): - application = Application.objects.create( - program=program, - user=request.user, - created_by=request.user, - **serializer.validated_data, - ) - except DjangoValidationError as exc: - existing_application = _active_application( + result = create_or_get_application( program=program, user=request.user, + created_by=request.user, + participation_mode=participation_mode, + form_data=validated_data.pop("form_data", None), + project=validated_data.pop("project", None), + team_name=team_name, ) - if existing_application: - return _application_response(existing_application, request) + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) + except DjangoValidationError as exc: raise ValidationError(exc.message_dict) from exc - except IntegrityError: - existing_application = _active_application( - program=program, - user=request.user, - ) - if existing_application: - return _application_response(existing_application, request) - raise return _application_response( - application, + result.application, request, - response_status=status.HTTP_201_CREATED, + response_status=( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ), ) @@ -148,8 +151,32 @@ def patch(self, request, application_id): context={"request": request}, ) serializer.is_valid(raise_exception=True) + participation_mode = serializer.validated_data.get("participation_mode") + team_name_supplied = "team_name" in serializer.validated_data + team_name = serializer.validated_data.get("team_name") try: - serializer.save() + with transaction.atomic(): + if participation_mode is not None or team_name_supplied: + application = change_application_participation_mode( + application=application, + actor=request.user, + participation_mode=( + participation_mode or application.participation_mode + ), + team_name=team_name if team_name_supplied else None, + ) + else: + application = Application.objects.select_for_update().get( + pk=application.pk + ) + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Изменить можно только черновик заявки." + ) + serializer.instance = application + serializer.save() + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) except DjangoValidationError as exc: raise ValidationError(exc.message_dict) from exc return Response(serializer.data, status=status.HTTP_200_OK) @@ -159,22 +186,20 @@ class ApplicationSubmitView(APIView): permission_classes = [IsAuthenticated] def post(self, request, application_id): - with transaction.atomic(): - application = get_object_or_404( - _application_queryset_for(request.user).select_for_update(), - pk=application_id, + application = get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + try: + application = submit_application( + application=application, + actor=request.user, ) - if application.status == Application.STATUS_SUBMITTED: - return _application_response(application, request) - if application.status != Application.STATUS_DRAFT: - raise ValidationError( - {"status": "Only draft applications can be submitted."} - ) - - application.status = Application.STATUS_SUBMITTED - application.submitted_at = timezone.now() - application.save(update_fields=["status", "submitted_at", "updated_at"]) - return _application_response(application, request) + except ApplicationTeamServiceError as exc: + _raise_domain_validation_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _application_response(application, request) class ApplicationWithdrawView(APIView): diff --git a/partner_programs/serializers/applications.py b/partner_programs/serializers/applications.py index 6673319b..10e2a018 100644 --- a/partner_programs/serializers/applications.py +++ b/partner_programs/serializers/applications.py @@ -5,6 +5,12 @@ class ApplicationSerializer(serializers.ModelSerializer): + team_name = serializers.CharField( + required=False, + allow_blank=True, + max_length=255, + write_only=True, + ) project = serializers.PrimaryKeyRelatedField( queryset=Project.objects.all(), allow_null=True, @@ -42,6 +48,8 @@ class Meta: "user", "created_by", "status", + "participation_mode", + "team_name", "form_data", "project", "project_id", @@ -67,8 +75,16 @@ class Meta: ) extra_kwargs = { "form_data": {"required": False}, + "participation_mode": {"required": False}, } + def update(self, instance, validated_data): + # Формат и Team изменяет только domain service; serializer сохраняет + # остальные редактируемые поля в общей транзакции view. + validated_data.pop("participation_mode", None) + validated_data.pop("team_name", None) + return super().update(instance, validated_data) + def validate(self, attrs): supplied_immutable_fields = self.immutable_input_fields.intersection( self.initial_data diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index 828cde87..b92c0240 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -1,3 +1,24 @@ +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationCreationResult, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + CaptainMemberMissingError, + CaptainMismatchError, + ParticipationModeNotAllowedError, + ParticipationModeUndecidedError, + RegistrationRequiredError, + TeamHasOtherMembersError, + TeamMemberRegistrationMissingError, + TeamNotAllowedError, + TeamRequiredError, + TeamSizeInvalidError, + change_application_participation_mode, + create_or_get_application, + submit_application, + validate_team_invariants, +) from partner_programs.services.exports import ( BASE_COLUMNS, ProgramExportFile, @@ -28,17 +49,34 @@ ) __all__ = [ + "ActiveApplicationConflictError", + "ApplicationCreationResult", + "ApplicationDeadlinePassedError", + "ApplicationNotEditableError", + "ApplicationTeamServiceError", "BASE_COLUMNS", + "CaptainMemberMissingError", + "CaptainMismatchError", + "ParticipationModeNotAllowedError", + "ParticipationModeUndecidedError", "ProgramExportFile", "ProgramProjectAlreadyApplied", "ProgramProjectApplicationResult", "ProgramProjectFilterError", "ProgramRegistrationError", "ProjectScoreDataPreparer", + "RegistrationRequiredError", + "TeamHasOtherMembersError", + "TeamMemberRegistrationMissingError", + "TeamNotAllowedError", + "TeamRequiredError", + "TeamSizeInvalidError", "apply_project_to_program", "build_program_field_columns", "build_program_project_scores_export_file", "build_program_projects_export_file", + "change_application_participation_mode", + "create_or_get_application", "create_user_and_register_to_program", "get_filterable_program_fields", "get_filtered_program_project_links", @@ -47,5 +85,7 @@ "register_user_to_program", "require_can_apply_project_to_program", "row_dict_for_link", + "submit_application", + "validate_team_invariants", "validate_program_project_filters", ] diff --git a/partner_programs/services/application_team.py b/partner_programs/services/application_team.py new file mode 100644 index 00000000..465c4640 --- /dev/null +++ b/partner_programs/services/application_team.py @@ -0,0 +1,555 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamMember, +) +from projects.models import Project + +User = get_user_model() + + +class ApplicationTeamServiceError(Exception): + """Базовая ошибка доменного сервиса Application/Team.""" + + code = "application_team_error" + default_detail = "Операция с заявкой недоступна." + default_field = "non_field_errors" + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field or self.default_field + super().__init__(self.detail) + + +class RegistrationRequiredError(ApplicationTeamServiceError): + code = "registration_required" + default_detail = "Сначала зарегистрируйтесь на активность." + default_field = "registration" + + +class ApplicationDeadlinePassedError(ApplicationTeamServiceError): + code = "application_deadline_passed" + default_detail = "Срок подачи заявок завершен." + default_field = "datetime_application_ends" + + +class ParticipationModeNotAllowedError(ApplicationTeamServiceError): + code = "participation_mode_not_allowed" + default_detail = "Выбранный формат участия недоступен для этой активности." + default_field = "participation_mode" + + +class ParticipationModeUndecidedError(ApplicationTeamServiceError): + code = "participation_mode_undecided" + default_detail = "Перед отправкой выберите формат участия." + default_field = "participation_mode" + + +class ActiveApplicationConflictError(ApplicationTeamServiceError): + code = "active_application_conflict" + default_detail = "Пользователь уже участвует в другой активной заявке." + default_field = "user" + + +class TeamRequiredError(ApplicationTeamServiceError): + code = "team_required" + default_detail = "Для командной заявки необходимо создать команду." + default_field = "team" + + +class TeamNotAllowedError(ApplicationTeamServiceError): + code = "team_not_allowed" + default_detail = "Для выбранного формата команда недопустима." + default_field = "team" + + +class TeamHasOtherMembersError(ApplicationTeamServiceError): + code = "team_has_other_members" + default_detail = "Нельзя сменить формат, пока в команде есть другие участники." + default_field = "team" + + +class CaptainMemberMissingError(ApplicationTeamServiceError): + code = "captain_member_missing" + default_detail = "В команде отсутствует единственный принятый капитан." + default_field = "team" + + +class CaptainMismatchError(ApplicationTeamServiceError): + code = "captain_mismatch" + default_detail = "Капитан команды не совпадает с владельцем заявки." + default_field = "team" + + +class TeamSizeInvalidError(ApplicationTeamServiceError): + code = "team_size_invalid" + default_detail = "Размер команды не соответствует настройкам активности." + default_field = "team" + + +class TeamMemberRegistrationMissingError(ApplicationTeamServiceError): + code = "team_member_registration_missing" + default_detail = "Не все принятые участники зарегистрированы на активность." + default_field = "team" + + +class ApplicationNotEditableError(ApplicationTeamServiceError): + code = "application_not_editable" + default_detail = "Заявку нельзя изменить в текущем состоянии." + default_field = "status" + + +@dataclass(frozen=True) +class ApplicationCreationResult: + """Результат idempotent create с признаком новой записи.""" + + application: Application + created: bool + + +def _lock_program(program: PartnerProgram) -> PartnerProgram: + # Одна строка Program служит общей точкой сериализации cross-table проверок + # Application и TeamMember внутри этого service. + return PartnerProgram.objects.select_for_update().get(pk=program.pk) + + +def _lock_application(application: Application) -> Application: + return ( + Application.objects.select_for_update() + .select_related("program", "user", "created_by", "project") + .get(pk=application.pk) + ) + + +def _require_registration(*, program: PartnerProgram, user: User) -> None: + if user is None or not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=user, + ).exists(): + raise RegistrationRequiredError() + + +def _require_open_application_deadline(program: PartnerProgram) -> None: + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def _validate_participation_mode( + *, + program: PartnerProgram, + participation_mode: str, + allow_undecided: bool, +) -> None: + known_modes = {value for value, _label in Application.PARTICIPATION_MODE_CHOICES} + if participation_mode not in known_modes: + raise ParticipationModeNotAllowedError() + if participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + if allow_undecided: + return + raise ParticipationModeUndecidedError() + if not program.allows_participation_mode(participation_mode): + raise ParticipationModeNotAllowedError() + + +def _active_application_ids_for_user( + *, + program: PartnerProgram, + user: User, +) -> set[int]: + owned_ids = Application.objects.filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ).values_list("pk", flat=True) + membership_ids = TeamMember.objects.select_for_update().filter( + user=user, + status=TeamMember.STATUS_ACCEPTED, + team__application__program=program, + team__application__status__in=Application.ACTIVE_STATUSES, + ).values_list("team__application_id", flat=True) + return set(owned_ids).union(membership_ids) + + +def _require_no_active_conflict( + *, + program: PartnerProgram, + user: User, + exclude_application: Application | None = None, +) -> None: + conflict_ids = _active_application_ids_for_user(program=program, user=user) + if exclude_application is not None: + conflict_ids.discard(exclude_application.pk) + if not conflict_ids: + return + + # Блокируем найденные Application после общей блокировки Program, чтобы + # последовательные domain-операции не приняли решения по устаревшим данным. + if Application.objects.select_for_update().filter(pk__in=conflict_ids).exists(): + raise ActiveApplicationConflictError() + + +def _create_team_with_captain( + *, + application: Application, + team_name: str | None, +) -> Team: + team = Team.objects.create( + application=application, + captain=application.user, + name=team_name or "", + ) + TeamMember.objects.create( + team=team, + user=application.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + invited_by=None, + ) + return team + + +def _existing_application_is_compatible( + *, + application: Application, + participation_mode: str, + form_data: dict | None, + project: Project | None, + team_name: str | None, +) -> bool: + if application.participation_mode != participation_mode: + return False + if form_data is not None and application.form_data != form_data: + return False + if project is not None and application.project_id != project.pk: + return False + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + team = Team.objects.select_for_update().filter(application=application).first() + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if team_name is not None and team.name != team_name: + return False + elif team_name: + raise TeamNotAllowedError() + return True + + +def create_or_get_application( + *, + program: PartnerProgram, + user: User, + created_by: User, + participation_mode: str, + form_data: dict | None = None, + project: Project | None = None, + team_name: str | None = None, +) -> ApplicationCreationResult: + """Создает draft Application или возвращает совместимую активную заявку. + + Аргументы описывают Program, владельца/автора, формат и начальные данные. + Возвращает ApplicationCreationResult с признаком создания. Может выбросить + ApplicationTeamServiceError при отсутствии Registration, закрытом дедлайне, + конфликте участия или несовместимой policy. Операция атомарна: для team + Application, Team и accepted-капитан либо создаются вместе, либо полностью + откатываются. + """ + with transaction.atomic(): + program = _lock_program(program) + _require_registration(program=program, user=user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=participation_mode, + allow_undecided=True, + ) + + existing_application = ( + Application.objects.select_for_update() + .filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ) + .order_by("-created_at") + .first() + ) + _require_no_active_conflict( + program=program, + user=user, + exclude_application=existing_application, + ) + if existing_application is not None: + if not _existing_application_is_compatible( + application=existing_application, + participation_mode=participation_mode, + form_data=form_data, + project=project, + team_name=team_name, + ): + raise ActiveApplicationConflictError( + "Активная заявка несовместима с повторным запросом." + ) + return ApplicationCreationResult(existing_application, created=False) + + if participation_mode != Application.PARTICIPATION_MODE_TEAM and team_name: + raise TeamNotAllowedError() + + try: + application = Application.objects.create( + program=program, + user=user, + created_by=created_by, + participation_mode=participation_mode, + form_data={} if form_data is None else form_data, + project=project, + status=Application.STATUS_DRAFT, + ) + except IntegrityError as exc: + raise ActiveApplicationConflictError() from exc + + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + _create_team_with_captain( + application=application, + team_name=team_name, + ) + + return ApplicationCreationResult(application, created=True) + + +def _locked_team(application: Application) -> Team | None: + return ( + Team.objects.select_for_update() + .select_related("application", "captain") + .filter(application=application) + .first() + ) + + +def _require_valid_captain_member(*, application: Application, team: Team) -> None: + if team.application_id != application.pk or team.captain_id != application.user_id: + raise CaptainMismatchError() + + captain_members = list( + TeamMember.objects.filter( + team=team, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ) + if len(captain_members) != 1: + raise CaptainMemberMissingError() + if captain_members[0].user_id != team.captain_id: + raise CaptainMismatchError() + + +def change_application_participation_mode( + *, + application: Application, + actor: User, + participation_mode: str, + team_name: str | None = None, +) -> Application: + """Меняет формат draft Application и синхронно управляет базовой Team. + + Принимает заявку, владельца-actor, новый формат и необязательное название + команды. Возвращает обновленную Application. Может выбросить доменные + ошибки доступа, Registration, deadline, policy, конфликта или структуры + Team. Транзакция гарантирует атомарное создание/удаление Team и капитана; + переход team → individual разрешен только для команды без других записей. + """ + with transaction.atomic(): + program = _lock_program(application.program) + application = _lock_application(application) + if application.user_id != actor.pk: + raise ApplicationNotEditableError( + "Только владелец может изменить формат заявки." + ) + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError() + + _require_registration(program=program, user=application.user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=participation_mode, + allow_undecided=True, + ) + _require_no_active_conflict( + program=program, + user=application.user, + exclude_application=application, + ) + + current_mode = application.participation_mode + team = _locked_team(application) + + if current_mode == participation_mode: + if current_mode == Application.PARTICIPATION_MODE_TEAM: + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if team_name is not None and team.name != team_name: + team.name = team_name + team.save(update_fields=["name", "updated_at"]) + elif team is not None: + raise TeamNotAllowedError() + elif team_name: + raise TeamNotAllowedError() + return application + + if current_mode == Application.PARTICIPATION_MODE_TEAM: + if team is None: + raise TeamRequiredError() + _require_valid_captain_member(application=application, team=team) + if participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + raise TeamNotAllowedError( + "Командную заявку нельзя вернуть в неопределенный формат." + ) + if participation_mode == Application.PARTICIPATION_MODE_INDIVIDUAL: + # Любая дополнительная запись сохраняет историю состава, поэтому + # team → individual допустим только для единственного капитана. + if team.members.exclude( + role=TeamMember.ROLE_CAPTAIN, + user=team.captain, + ).exists(): + raise TeamHasOtherMembersError() + team.delete() + team = None + elif team is not None: + raise TeamNotAllowedError() + + if participation_mode == Application.PARTICIPATION_MODE_TEAM: + application.participation_mode = participation_mode + application.save(update_fields=["participation_mode", "updated_at"]) + _create_team_with_captain( + application=application, + team_name=team_name, + ) + return application + + if team_name: + raise TeamNotAllowedError() + application.participation_mode = participation_mode + application.save(update_fields=["participation_mode", "updated_at"]) + return application + + +def validate_team_invariants(application: Application) -> None: + """Проверяет полный invariant Team, не изменяя Application или команду. + + Для individual требует отсутствие Team, для undecided запрещает submit, а + для team проверяет капитана, Registration accepted-участников и границы + размера из Program policy. Возвращаемого значения нет; нарушение выражается + специализированной ApplicationTeamServiceError. + """ + if application.participation_mode == Application.PARTICIPATION_MODE_UNDECIDED: + raise ParticipationModeUndecidedError() + + team = Team.objects.select_related("application", "captain").filter( + application=application + ).first() + if application.participation_mode == Application.PARTICIPATION_MODE_INDIVIDUAL: + if team is not None: + raise TeamNotAllowedError() + return + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + raise ParticipationModeNotAllowedError() + if team is None: + raise TeamRequiredError() + + _require_valid_captain_member(application=application, team=team) + # Invited/declined/removed/left сохраняют историю, но участниками команды + # при submit считаются только accepted-записи, включая капитана. + accepted_members = list( + TeamMember.objects.filter(team=team, status=TeamMember.STATUS_ACCEPTED) + .select_related("user") + ) + accepted_user_ids = {member.user_id for member in accepted_members} + registered_user_ids = set( + PartnerProgramUserProfile.objects.filter( + partner_program=application.program, + user_id__in=accepted_user_ids, + ).values_list("user_id", flat=True) + ) + if accepted_user_ids != registered_user_ids: + raise TeamMemberRegistrationMissingError() + + accepted_count = len(accepted_members) + minimum = application.program.team_min_size + maximum = application.program.team_max_size + if ( + minimum is None + or maximum is None + or accepted_count < minimum + or accepted_count > maximum + ): + raise TeamSizeInvalidError() + + +def _require_no_team_member_conflicts(application: Application) -> None: + if application.participation_mode == Application.PARTICIPATION_MODE_TEAM: + user_ids = TeamMember.objects.filter( + team__application=application, + status=TeamMember.STATUS_ACCEPTED, + ).values_list("user_id", flat=True) + else: + user_ids = [application.user_id] + + for user_id in user_ids: + _require_no_active_conflict( + program=application.program, + user=User.objects.get(pk=user_id), + exclude_application=application, + ) + + +def submit_application(*, application: Application, actor: User) -> Application: + """Валидирует и атомарно отправляет Application. + + Принимает заявку и actor; владельцу и staff сохраняет текущий contract + доступа. Возвращает submitted Application. Повторный submit идемпотентен и + не меняет submitted_at даже после deadline. Для draft возможны доменные + ошибки Registration, deadline, policy, конфликта или Team invariant. Все + проверки и переход выполняются в одной транзакции. + """ + with transaction.atomic(): + program = _lock_program(application.program) + application = _lock_application(application) + if application.user_id != actor.pk and not ( + actor.is_staff or actor.is_superuser + ): + raise ApplicationNotEditableError( + "Только владелец или staff может отправить заявку." + ) + + # Идемпотентный повтор не зависит от изменившегося deadline и не должен + # повторно записывать submitted_at. + if application.status == Application.STATUS_SUBMITTED: + return application + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Отправить можно только черновик заявки." + ) + + _require_registration(program=program, user=application.user) + _require_open_application_deadline(program) + _validate_participation_mode( + program=program, + participation_mode=application.participation_mode, + allow_undecided=False, + ) + validate_team_invariants(application) + _require_no_team_member_conflicts(application) + + application.status = Application.STATUS_SUBMITTED + application.submitted_at = timezone.now() + application.save(update_fields=["status", "submitted_at", "updated_at"]) + return application diff --git a/partner_programs/tests/test_application_api.py b/partner_programs/tests/test_application_api.py index 932007ba..96f9ae4a 100644 --- a/partner_programs/tests/test_application_api.py +++ b/partner_programs/tests/test_application_api.py @@ -6,11 +6,15 @@ from partner_programs.models import ( Application, + PartnerProgram, PartnerProgramProject, PartnerProgramUserProfile, + Team, + TeamMember, ) from partner_programs.tests.helpers import ( create_partner_program, + create_program_member, create_project, create_user, ) @@ -34,6 +38,7 @@ def setUp(self): self.other_user = create_user(prefix="application-api-other") self.staff_user = create_user(prefix="application-api-staff", is_staff=True) self.program = create_partner_program() + self.registration = create_program_member(self.program, user=self.user) def authenticate(self, user=None): self.client.force_authenticate(user=user or self.user) @@ -80,10 +85,66 @@ def test_authenticated_user_can_create_draft_application(self): self.assertIsNone(application.project) self.assertEqual(response.data["id"], application.id) self.assertEqual(response.data["project"], None) - self.assertFalse(PartnerProgramUserProfile.objects.exists()) + self.assertEqual( + response.data["participation_mode"], + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertEqual(PartnerProgramUserProfile.objects.count(), 1) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.registration.pk).exists() + ) self.assertFalse(PartnerProgramProject.objects.exists()) self.assertFalse(Project.objects.exists()) + def test_application_create_requires_program_registration(self): + unregistered_user = create_user(prefix="application-api-unregistered") + self.authenticate(unregistered_user) + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("registration", response.data) + self.assertFalse(Application.objects.exists()) + + def test_team_application_can_be_created_through_extended_request(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ), + team_min_size=2, + team_max_size=5, + ) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "team_name": "API team", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + application = Application.objects.get() + self.assertEqual( + response.data["participation_mode"], + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertNotIn("team_name", response.data) + self.assertEqual(application.team.name, "API team") + self.assertTrue( + application.team.members.filter( + user=self.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + ) + def test_my_application_returns_current_users_application(self): application = self.create_application() self.authenticate() @@ -122,7 +183,7 @@ def test_repeated_create_returns_existing_active_application(self): ) second_response = self.client.post( url, - {"form_data": {"version": 2}}, + {"form_data": {"version": 1}}, format="json", ) @@ -146,6 +207,56 @@ def test_patch_draft_updates_form_data(self): application.refresh_from_db() self.assertEqual(application.form_data, {"version": 2}) + def test_patch_participation_mode_uses_team_service(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ), + team_min_size=2, + team_max_size=5, + ) + application = self.create_application() + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "team_name": "Patched team", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertEqual(Team.objects.get(application=application).name, "Patched team") + + def test_patch_rolls_back_other_fields_when_mode_change_fails(self): + application = self.create_application(form_data={"version": 1}) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "participation_mode": Application.PARTICIPATION_MODE_TEAM, + "form_data": {"version": 2}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertEqual(application.form_data, {"version": 1}) + self.assertFalse(Team.objects.filter(application=application).exists()) + def test_patch_rejects_immutable_application_fields(self): application = self.create_application(form_data={"version": 1}) other_program = create_partner_program() diff --git a/partner_programs/tests/test_application_team_service.py b/partner_programs/tests/test_application_team_service.py new file mode 100644 index 00000000..743a29ff --- /dev/null +++ b/partner_programs/tests/test_application_team_service.py @@ -0,0 +1,659 @@ +from unittest.mock import patch + +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram, Team, TeamMember +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + CaptainMemberMissingError, + CaptainMismatchError, + ParticipationModeNotAllowedError, + ParticipationModeUndecidedError, + RegistrationRequiredError, + TeamHasOtherMembersError, + TeamMemberRegistrationMissingError, + TeamNotAllowedError, + TeamRequiredError, + TeamSizeInvalidError, + change_application_participation_mode, + create_or_get_application, + submit_application, + validate_team_invariants, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class ApplicationTeamServiceTests(TestCase): + def setUp(self): + self.program = create_partner_program() + self.user = create_user(prefix="team-service-owner") + self.registration = create_program_member(self.program, user=self.user) + + def configure_team_policy( + self, + *, + participation_format=PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + minimum=2, + maximum=5, + ): + PartnerProgram.objects.filter(pk=self.program.pk).update( + participation_format=participation_format, + team_min_size=minimum, + team_max_size=maximum, + ) + self.program.refresh_from_db() + + def create_application( + self, + *, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + user=None, + program=None, + form_data=None, + team_name=None, + ): + owner = user or self.user + target_program = program or self.program + return create_or_get_application( + program=target_program, + user=owner, + created_by=owner, + participation_mode=participation_mode, + form_data=form_data, + team_name=team_name, + ) + + def add_team_member( + self, + application, + *, + user=None, + status=TeamMember.STATUS_ACCEPTED, + register=True, + ): + member_user = user or create_user(prefix="team-service-member") + if register: + create_program_member(application.program, user=member_user) + member = TeamMember.objects.create( + team=application.team, + user=member_user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=application.user, + ) + return member + + def test_registered_user_creates_individual_draft_without_team(self): + result = self.create_application(form_data={"motivation": "test"}) + + self.assertTrue(result.created) + self.assertEqual(result.application.status, Application.STATUS_DRAFT) + self.assertEqual( + result.application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertFalse(Team.objects.filter(application=result.application).exists()) + + def test_create_requires_registration(self): + self.registration.delete() + + with self.assertRaises(RegistrationRequiredError): + self.create_application() + + self.assertFalse(Application.objects.exists()) + + def test_create_is_blocked_after_application_deadline(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + self.create_application() + + def test_individual_is_forbidden_in_team_only_program(self): + self.configure_team_policy( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + ) + + with self.assertRaises(ParticipationModeNotAllowedError): + self.create_application() + + def test_repeated_compatible_create_returns_existing_application(self): + first = self.create_application(form_data={"version": 1}) + second = self.create_application(form_data={"version": 1}) + + self.assertTrue(first.created) + self.assertFalse(second.created) + self.assertEqual(first.application.pk, second.application.pk) + self.assertEqual(Application.objects.count(), 1) + + def test_repeated_incompatible_create_is_rejected(self): + self.create_application(form_data={"version": 1}) + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application(form_data={"version": 2}) + + def test_undecided_draft_can_be_created_without_team(self): + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ) + + self.assertEqual( + result.application.participation_mode, + Application.PARTICIPATION_MODE_UNDECIDED, + ) + self.assertFalse(Team.objects.filter(application=result.application).exists()) + + def test_undecided_application_cannot_be_submitted(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ).application + + with self.assertRaises(ParticipationModeUndecidedError): + submit_application(application=application, actor=self.user) + + def test_team_can_be_created_in_team_only_program(self): + self.configure_team_policy( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + ) + + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Команда", + ) + + self.assertTrue(result.created) + self.assertEqual(result.application.team.name, "Команда") + + def test_team_can_be_created_in_individual_or_team_program(self): + self.configure_team_policy() + + result = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + self.assertTrue(Team.objects.filter(application=result.application).exists()) + + def test_team_is_forbidden_in_individual_only_program(self): + with self.assertRaises(ParticipationModeNotAllowedError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + def test_team_create_atomically_creates_matching_accepted_captain(self): + self.configure_team_policy() + + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + captain_member = application.team.members.get(role=TeamMember.ROLE_CAPTAIN) + + self.assertEqual(application.team.captain, application.user) + self.assertEqual(captain_member.user, application.user) + self.assertEqual(captain_member.status, TeamMember.STATUS_ACCEPTED) + self.assertIsNone(captain_member.invited_by) + self.assertIsNotNone(captain_member.joined_at) + + def test_team_create_rolls_back_application_after_internal_error(self): + self.configure_team_policy() + + with patch( + "partner_programs.services.application_team._create_team_with_captain", + side_effect=RuntimeError("forced error"), + ): + with self.assertRaises(RuntimeError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + self.assertFalse(Application.objects.exists()) + self.assertFalse(Team.objects.exists()) + self.assertFalse(TeamMember.objects.exists()) + + def test_owner_cannot_create_incompatible_second_active_application(self): + self.configure_team_policy() + self.create_application() + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + def test_accepted_member_of_another_application_has_conflict(self): + self.configure_team_policy() + captain = create_user(prefix="other-team-captain") + create_program_member(self.program, user=captain) + other_application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + user=captain, + ).application + self.add_team_member(other_application, user=self.user, register=False) + + with self.assertRaises(ActiveApplicationConflictError): + self.create_application() + + def test_terminal_application_does_not_block_new_application(self): + first = self.create_application().application + first.status = Application.STATUS_WITHDRAWN + first.withdrawn_at = timezone.now() + first.save(update_fields=["status", "withdrawn_at", "updated_at"]) + + second = self.create_application() + + self.assertTrue(second.created) + self.assertNotEqual(first.pk, second.application.pk) + + def test_membership_in_another_program_does_not_block_create(self): + other_program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + captain = create_user(prefix="other-program-captain") + create_program_member(other_program, user=captain) + create_program_member(other_program, user=self.user) + other_application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + user=captain, + program=other_program, + ).application + self.add_team_member( + other_application, + user=self.user, + register=False, + ) + + result = self.create_application() + + self.assertTrue(result.created) + + def test_individual_to_team_creates_team_and_captain(self): + self.configure_team_policy() + application = self.create_application().application + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Новая команда", + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + self.assertEqual(updated.team.name, "Новая команда") + self.assertTrue( + updated.team.members.filter( + user=self.user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + ) + + def test_team_to_individual_deletes_captain_only_team(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + team_id = application.team.pk + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + self.assertFalse(Team.objects.filter(pk=team_id).exists()) + self.assertFalse(TeamMember.objects.filter(team_id=team_id).exists()) + + def test_team_name_can_change_for_draft_before_deadline(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Старое название", + ).application + + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Новое название", + ) + + application.team.refresh_from_db() + self.assertEqual(application.team.name, "Новое название") + + def test_only_owner_can_change_participation_mode(self): + application = self.create_application().application + another_user = create_user(prefix="mode-change-actor") + + with self.assertRaises(ApplicationNotEditableError): + change_application_participation_mode( + application=application, + actor=another_user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_team_to_individual_is_forbidden_with_another_member_record(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member( + application, + status=TeamMember.STATUS_INVITED, + register=False, + ) + + with self.assertRaises(TeamHasOtherMembersError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + application.refresh_from_db() + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_TEAM, + ) + + def test_team_to_undecided_is_forbidden(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + + with self.assertRaises(TeamNotAllowedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_individual_to_undecided_is_allowed_for_draft(self): + application = self.create_application().application + + updated = change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + self.assertEqual( + updated.participation_mode, + Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_cannot_change_after_submit(self): + application = self.create_application().application + submit_application(application=application, actor=self.user) + + with self.assertRaises(ApplicationNotEditableError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_cannot_change_after_deadline(self): + application = self.create_application().application + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED, + ) + + def test_participation_mode_must_match_program_policy(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ).application + + with self.assertRaises(ParticipationModeNotAllowedError): + change_application_participation_mode( + application=application, + actor=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + + def test_team_invariant_requires_team(self): + self.configure_team_policy() + application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + + with self.assertRaises(TeamRequiredError): + validate_team_invariants(application) + + def test_team_invariant_requires_captain_member(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + application.team.members.all().delete() + + with self.assertRaises(CaptainMemberMissingError): + validate_team_invariants(application) + + def test_team_invariant_rejects_captain_mismatch(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + another_user = create_user(prefix="mismatched-captain") + Team.objects.filter(pk=application.team.pk).update(captain=another_user) + + with self.assertRaises(CaptainMismatchError): + validate_team_invariants(application) + + def test_team_invariant_requires_registration_for_accepted_member(self): + self.configure_team_policy() + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application, register=False) + + with self.assertRaises(TeamMemberRegistrationMissingError): + validate_team_invariants(application) + + def test_team_invariant_rejects_team_smaller_than_minimum(self): + self.configure_team_policy(minimum=2, maximum=5) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + + with self.assertRaises(TeamSizeInvalidError): + validate_team_invariants(application) + + def test_team_invariant_rejects_team_larger_than_maximum(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + self.add_team_member(application) + + with self.assertRaises(TeamSizeInvalidError): + validate_team_invariants(application) + + def test_valid_accepted_team_passes_invariant(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + + validate_team_invariants(application) + + def test_invited_member_is_not_counted_in_team_size(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + self.add_team_member( + application, + status=TeamMember.STATUS_INVITED, + register=False, + ) + + validate_team_invariants(application) + + def test_inactive_member_statuses_are_not_counted_in_team_size(self): + self.configure_team_policy(minimum=2, maximum=2) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + for member_status in ( + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.add_team_member( + application, + status=member_status, + register=False, + ) + + validate_team_invariants(application) + + def test_individual_submit_transitions_application(self): + application = self.create_application().application + + submitted = submit_application(application=application, actor=self.user) + + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + self.assertIsNotNone(submitted.submitted_at) + + def test_team_submit_with_valid_team_transitions_application(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + self.add_team_member(application) + + submitted = submit_application(application=application, actor=self.user) + + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + + def test_repeated_submit_is_idempotent(self): + application = self.create_application().application + first = submit_application(application=application, actor=self.user) + + second = submit_application(application=first, actor=self.user) + + self.assertEqual(second.status, Application.STATUS_SUBMITTED) + self.assertEqual(second.pk, first.pk) + + def test_repeated_submit_preserves_submitted_at(self): + application = self.create_application().application + first = submit_application(application=application, actor=self.user) + submitted_at = first.submitted_at + + second = submit_application(application=first, actor=self.user) + + self.assertEqual(second.submitted_at, submitted_at) + + def test_draft_submit_is_blocked_after_deadline(self): + application = self.create_application().application + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + with self.assertRaises(ApplicationDeadlinePassedError): + submit_application(application=application, actor=self.user) + + def test_submitted_application_is_idempotent_after_deadline(self): + application = self.create_application().application + submitted = submit_application(application=application, actor=self.user) + submitted_at = submitted.submitted_at + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=timezone.now() - timezone.timedelta(seconds=1) + ) + + repeated = submit_application(application=submitted, actor=self.user) + + self.assertEqual(repeated.submitted_at, submitted_at) + + def test_member_conflict_blocks_team_submit(self): + self.configure_team_policy(minimum=2, maximum=3) + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ).application + member = self.add_team_member(application) + Application.objects.create( + program=self.program, + user=member.user, + created_by=member.user, + ) + + with self.assertRaises(ActiveApplicationConflictError): + submit_application(application=application, actor=self.user) + + def test_submit_requires_owner_registration(self): + self.registration.delete() + application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + + with self.assertRaises(RegistrationRequiredError): + submit_application(application=application, actor=self.user) + + +class ApplicationTeamServiceSequentialConflictTests(TransactionTestCase): + def test_sequential_operations_do_not_create_second_active_participation(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + team_min_size=2, + team_max_size=5, + ) + captain = create_user(prefix="sequential-captain") + member = create_user(prefix="sequential-member") + create_program_member(program, user=captain) + create_program_member(program, user=member) + application = create_or_get_application( + program=program, + user=captain, + created_by=captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + TeamMember.objects.create( + team=application.team, + user=member, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=captain, + ) + + with self.assertRaises(ActiveApplicationConflictError): + create_or_get_application( + program=program, + user=member, + created_by=member, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + self.assertEqual(Application.objects.count(), 1)