diff --git a/docs/application-team-model.md b/docs/application-team-model.md new file mode 100644 index 00000000..27021cc4 --- /dev/null +++ b/docs/application-team-model.md @@ -0,0 +1,137 @@ +# Application Team Model + +## Назначение + +Базовый слой командных заявок разделяет три понятия: + +- `Application` — заявка на одну партнерскую программу; +- `Team` — команда, собранная только для этой заявки; +- `TeamMember` — членство пользователя в команде заявки. + +Публичного Team API и атомарного service создания команды пока нет. Модели +подготавливают структуру данных для следующих PR, не меняя текущий +индивидуальный API flow. + +## Participation mode + +В `Application` добавлено поле `participation_mode`: + +| Значение | Смысл | +|---|---| +| `undecided` | Формат участия еще не выбран | +| `individual` | Индивидуальная заявка | +| `team` | Командная заявка | + +Runtime default временно равен `individual`: существующие API и frontend не +передают новое поле, поэтому все текущие и исторические Application продолжают +работать как индивидуальные. `undecided` должен стать default одновременно с +wizard и запретом submit заявки без выбранного формата. + +Миграция `0020_team_application_participation_mode_teammember_and_more` +добавляет поле с default `individual`; Django применяет это значение ко всем +существующим строкам Application. + +## Team + +`Team` содержит: + +- one-to-one `application` с `related_name="team"`; +- необязательное на стадии черновика `name` длиной до 255 символов; +- `captain`; +- `created_at` и `updated_at`. + +Team относится к Application, потому что состав команды может различаться в +разных активностях даже при использовании одного Project. Существующий +`projects.Collaborator` описывает постоянного участника Project и намеренно не +переиспользуется как TeamMember. + +В первом MVP `Team.captain` обязан совпадать с `Application.user`. +`Team.status`, invite code, token и настройки размера команды не добавлены: +редактируемость будущего Team flow должна выводиться из `Application.status`. + +## TeamMember + +`TeamMember` содержит: + +- `team` и `user`; +- роль `captain` или `member`; +- статус `invited`, `accepted`, `declined`, `removed` или `left`; +- nullable `invited_by`; +- nullable `joined_at`; +- `created_at` и `updated_at`. + +Для первоначального captain member `invited_by` остается `null`: капитан не +принимает собственное приглашение, а создается как владелец команды. Статус +`invited` для обычных участников пока является только модельной заготовкой; +механизм TeamInvite в этом PR отсутствует. + +При первом сохранении статуса `accepted` модель автоматически заполняет +`joined_at`, если дата не передана. При переходе в `removed` или `left` дата не +очищается и сохраняет момент фактического присоединения. + +## Database constraints + +На уровне БД обеспечены: + +- одна Team на Application через `OneToOneField`; +- уникальность `TeamMember(team, user)`; +- не более одного `accepted` TeamMember с ролью `captain` в Team; +- check: роль `captain` допустима только со статусом `accepted`. + +Простым constraint одной таблицы нельзя надежно обеспечить: + +- участие пользователя в нескольких Team разных Application одной Program; +- конфликт TeamMember с индивидуальной Application той же Program; +- Registration всех членов команды; +- размер команды; +- блокировку состава после submit. + +Эти правила проходят через Application, Team и TeamMember, поэтому требуют +отдельного транзакционного domain service с блокировкой строк. + +## Model validation + +`Team.clean()` проверяет: + +- `Application.participation_mode == team`; +- `Team.captain == Application.user`. + +Это запрещает Team для `individual` и `undecided` Application. + +`TeamMember.clean()` проверяет: + +- captain member имеет статус `accepted`; +- его `user` совпадает с `Team.captain`; +- для первого принятия заполнен `joined_at`. + +Наличие captain TeamMember намеренно не проверяется при первом `Team.save()`. +Такая проверка создала бы цикл: Team должна быть сохранена до создания +TeamMember, а TeamMember уже требует сохраненную Team. + +## Текущий технический порядок создания + +До появления публичного API техническая последовательность выглядит так: + +1. создать или обновить Application с `participation_mode=team`; +2. создать Team с captain, совпадающим с `Application.user`; +3. создать accepted TeamMember с `role=captain` и тем же пользователем. + +Клиентам нельзя использовать эту последовательность напрямую. Сейчас нет +публичных serializers/views/URLs и транзакционного service, который откатит +частично созданную команду. + +## Вне текущего 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 этим слоем данных не изменяются. diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index a609cc80..371c0818 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -2,11 +2,12 @@ Статус: целевая доменная модель и аудит текущей реализации backend. -Актуальность аудита: ветка `master`, коммит `ff0ee65` от 21 июля 2026 года. +Актуальность аудита: после добавления базовых моделей Team и TeamMember, +21 июля 2026 года. Документ развивает `docs/application-project-submission-rfc.md` с учетом уже -реализованных `Application` и `Submission`. Он не является описанием готового -контракта для еще не существующих сущностей команды, приглашений, оценки и +реализованных `Application`, `Team`, `TeamMember` и `Submission`. Он не является +описанием готового контракта для еще не существующих приглашений, оценки и результата. ## 1. Назначение документа @@ -94,8 +95,9 @@ legacy `Project`. проекта. **Текущее состояние.** Реализована как `partner_programs.Application`. -Текущий MVP только индивидуальный: `user` обязателен через `clean()`, а -`participation_mode` и связь с `Team` отсутствуют. `created_by` обязателен. +`user` и `created_by` обязательны для текущего MVP. Поле `participation_mode` +поддерживает `undecided/individual/team`, но runtime default временно остается +`individual`, а существующий API поле не принимает и не валидирует. ### Team @@ -110,7 +112,9 @@ legacy `Project`. collaborators проекта и состояние email-приглашений. `invite_code` допустим только как временный MVP-механизм; предпочтительнее отдельная `TeamInvite`. -**Текущее состояние.** Отсутствует. `projects.Collaborator` не является +**Текущее состояние.** Реализована как `partner_programs.Team` с one-to-one +Application, названием, капитаном и timestamps. Публичного Team API и +транзакционного creation service пока нет. `projects.Collaborator` не является заменой: он связан с долгоживущим `Project`, а не с заявкой на одну программу. ### TeamMember @@ -126,8 +130,10 @@ collaborators проекта и состояние email-приглашений. **Что не должна хранить.** Профиль проекта, ответы заявки или независимый статус самой заявки. -**Текущее состояние.** Отсутствует. `projects.Collaborator` хранит участников -проекта, но не роли и lifecycle команды заявки. +**Текущее состояние.** Реализована как `partner_programs.TeamMember` с ролями, +пятью статусами, `invited_by` и `joined_at`. `projects.Collaborator` продолжает +хранить только участников проекта. Механизма приглашений и cross-Application +проверок участия еще нет. ### TeamInvite @@ -237,15 +243,16 @@ email-инфраструктура и WebSocket consumer `NotificationConsumer` отдельно, но полноценный `project_snapshot` отсутствует. Потребители не должны считать актуальные поля связанного Project частью отправленной заявки. -7. **Команда относится к конкретной заявке.** Это целевое правило; Team еще не - реализована. -8. **Команда проекта и команда заявки могут различаться.** Будущую Team нельзя - строить как alias для `Collaborator`. +7. **Команда относится к конкретной заявке.** Это обеспечено one-to-one связью + `Team.application`. +8. **Команда проекта и команда заявки могут различаться.** Team реализована + отдельно от `projects.Collaborator`. 9. **Пользователь участвует только в одной активной заявке одной программы.** Сейчас правило обеспечено только для `Application.user`; будущие члены команд этим constraint не покрыты. -10. **В MVP заявку и решение отправляет только капитан.** Сейчас капитана нет; - действия доступны владельцу индивидуальной Application и staff. +10. **В MVP заявку и решение отправляет только капитан.** Captain уже хранится + в Team и совпадает с `Application.user`, но существующие endpoints еще не + используют Team-aware permissions. 11. **Членами команды считаются только принятые участники.** Это правило должно опираться на `TeamMember.status = accepted`, а не на наличие TeamInvite. 12. **Submission относится к конкретной Application.** Уже обеспечено FK и @@ -333,7 +340,7 @@ Application. Возможность создать после них новую ## 6. Формат участия -В `Application` требуется целевое поле `participation_mode`: +В `Application` реализовано поле `participation_mode`: | Значение | Смысл | |---|---| @@ -355,8 +362,9 @@ Application. Возможность создать после них новую - переход `individual → team` должен создавать Team явным действием, а не побочным эффектом чтения Application. -Сейчас `participation_mode` отсутствует, `Application.user` обязателен, а -модельный docstring прямо фиксирует индивидуальный MVP. +Сейчас runtime default равен `individual` для обратной совместимости. Serializer +и endpoints специально не изменены; выбор `undecided/team` станет частью +отдельного API/UI PR вместе с submit validation. ## 7. Команда заявки @@ -412,6 +420,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. + ## 8. Приглашения Будущая `TeamInvite` должна поддерживать три канала: @@ -602,24 +615,24 @@ Registration. | Область | Уже реализовано | Частично | Отсутствует | Комментарий | |---|---|---|---|---| | Registration | `PartnerProgramUserProfile`, register endpoints, `participating=1`, deadline и uniqueness | Статус Registration и связь с новым flow | Отдельная модель/явный contract Registration | Application create не требует Registration | -| Application model | Program, user, created_by, form_data, nullable Project, timestamps, partial unique constraint | Только индивидуальный ownership | `participation_mode`, Team, returned, snapshot, review reason | Модель и admin существуют | +| 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 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 | Нет | Legacy `Collaborator` только для Project | Team целиком | Legacy сущность нельзя переименовать или переиспользовать неявно | -| TeamMember | Нет | `Collaborator` хранит project membership | TeamMember целиком | Нет ролей/статусов команды заявки | +| Team | One-to-one Application, name, captain, timestamps, model validation, admin | Нет domain service и 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 и legacy project submission/evaluation dates | Поля Program можно переиспользовать только после решения semantics | Проверки в Application/Submission API, отдельные application/solution deadlines | Сейчас новый flow позволяет действия после дедлайнов | | Permissions | Owner/staff для новых API; manager/expert permissions есть для legacy flow | Staff имеет расширенный доступ | Captain/member, manager и expert permissions для новых сущностей | Нужен общий domain permission layer | -| Constraints | Registration uniqueness; active individual Application; Submission version; Project collaborators | Часть правил в `clean()` и views | Cross-team active participation, captain invariant, team size | Cross-table race требует транзакционного service | -| Admin | PartnerProgram, Registration, Application, Submission, Project, Invite и legacy evaluation зарегистрированы | Admin позволяет ручную диагностику | Team/TeamMember/TeamInvite/Evaluation admin | Admin не заменяет transition services | -| Tests | Подробные model/API tests Application и Submission; regression tests регистрации, project apply, deadlines и permissions | Нет интеграционного end-to-end нового flow | Team/invite/evaluation/result tests | Основной individual Application/Submission MVP хорошо покрыт | +| 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 | +| 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 покрыты отдельно | ### Актуальные domain endpoints @@ -649,6 +662,8 @@ Registration. ### Admin и тесты - `ApplicationAdmin` показывает Program, owner/creator, status, Project и даты; +- `TeamAdmin` и `TeamMemberAdmin` показывают Program, капитана, роли, статусы и + даты командного слоя; - `SubmissionAdmin` показывает Application, Program, submitter, status, `stage_key`, version и даты; - legacy admin продолжает обслуживать Registration, PartnerProgramProject, @@ -664,12 +679,12 @@ Registration. | Требование | Текущее состояние | Требуемое изменение | Приоритет | Рекомендуемый PR | |---|---|---|---|---| -| Явный формат участия | Все Application индивидуальные | Добавить `participation_mode` с безопасной миграцией | P0 | `feature/application-team-model` | -| Team и TeamMember | Отсутствуют | Добавить модели, admin, базовые constraints и tests | P0 | `feature/application-team-model` | +| Явный формат участия | Поле и безопасный 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 | Настройки формата/размера Program и submit validation | P0 | Program participation policy | -| Captain-only actions | Нет капитана | Team-aware permissions для Application и Submission | P0 | Team permissions | +| Валидация команды перед submit | Есть модель Team, но нет program policy/service | Настройки формата/размера Program и submit validation | P0 | Program participation policy | +| 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 | | TeamInvite | Есть только Project Invite | Отдельные token/email/user invites и идемпотентный accept | P1 | TeamInvite model and API | @@ -686,8 +701,9 @@ Registration. ## 15. Рекомендуемый порядок реализации -1. **`feature/application-team-model`.** Добавить `participation_mode`, Team и - TeamMember с миграциями, admin, модельными ограничениями и тестами; без API. +1. **`feature/application-team-model` — реализовано.** Добавлены + `participation_mode`, Team и TeamMember с миграцией, admin, модельными + ограничениями и тестами; без API. 2. **Program participation policy.** Зафиксировать разрешенные форматы, минимальный/максимальный размер команды и реальные дедлайны Application. 3. **Application/team invariants.** Ввести транзакционный service для проверки @@ -721,10 +737,13 @@ Registration. Каждый PR должен содержать собственные model/API tests и не менять legacy endpoints без отдельной миграционной задачи. -## 16. Первый рекомендуемый coding PR +## 16. Реализованный базовый coding PR Ветка: `feature/application-team-model`. +Статус: базовый слой данных реализован; serializers, публичный API и +транзакционный creation/invariant service намеренно отсутствуют. + ### Точный scope 1. Добавить в `Application` поле `participation_mode` с choices @@ -758,8 +777,9 @@ endpoints без отдельной миграционной задачи. будущем domain service и обязательно перед submit Application. 10. Зарегистрировать Team и TeamMember в Django admin с фильтрами по Program, статусу и роли. -11. Добавить model/migration tests для choices, миграции существующих данных, - one-to-one, uniqueness и validation. +11. Добавлены model tests для choices, one-to-one, uniqueness и validation. + Существующие строки получают `individual` непосредственно через default + операции `AddField`; отдельного migration-test convention в проекте нет. ### Намеренно вне scope diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 8251afa6..ed9831bf 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -19,6 +19,8 @@ PartnerProgramProject, PartnerProgramUserProfile, Submission, + Team, + TeamMember, ) from partner_programs.services import prepare_project_scores_export_data @@ -30,6 +32,7 @@ class ApplicationAdmin(admin.ModelAdmin): "program", "user", "created_by", + "participation_mode", "status", "project", "submitted_at", @@ -38,6 +41,7 @@ class ApplicationAdmin(admin.ModelAdmin): ) list_filter = ( "status", + "participation_mode", "program", "created_at", ) @@ -61,6 +65,100 @@ class ApplicationAdmin(admin.ModelAdmin): date_hierarchy = "created_at" +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ( + "id", + "application", + "get_program", + "name", + "captain", + "created_at", + "updated_at", + ) + list_filter = ( + "application__program", + "created_at", + ) + search_fields = ( + "name", + "captain__email", + "captain__first_name", + "captain__last_name", + "application__program__name", + "application__program__tag", + "=application__id", + ) + raw_id_fields = ( + "application", + "captain", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "application", + "application__program", + "captain", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="application__program") + def get_program(self, obj): + return obj.application.program + + +@admin.register(TeamMember) +class TeamMemberAdmin(admin.ModelAdmin): + list_display = ( + "id", + "team", + "get_program", + "user", + "role", + "status", + "invited_by", + "joined_at", + "created_at", + ) + list_filter = ( + "role", + "status", + "team__application__program", + "created_at", + ) + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "team__name", + "team__application__program__name", + "team__application__program__tag", + ) + raw_id_fields = ( + "team", + "user", + "invited_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "team", + "team__application", + "team__application__program", + "user", + "invited_by", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="team__application__program") + def get_program(self, obj): + return obj.team.application.program + + @admin.register(Submission) class SubmissionAdmin(admin.ModelAdmin): list_display = ( diff --git a/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py b/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py new file mode 100644 index 00000000..fcca6f89 --- /dev/null +++ b/partner_programs/migrations/0020_team_application_participation_mode_teammember_and_more.py @@ -0,0 +1,162 @@ +# Generated by Django 4.2.11 on 2026-07-21 12:16 + +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", "0019_submission"), + ] + + operations = [ + migrations.CreateModel( + name="Team", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Команда заявки", + "verbose_name_plural": "Команды заявок", + }, + ), + migrations.AddField( + model_name="application", + name="participation_mode", + field=models.CharField( + choices=[ + ("undecided", "Не определен"), + ("individual", "Индивидуально"), + ("team", "В команде"), + ], + default="individual", + max_length=16, + ), + ), + migrations.CreateModel( + name="TeamMember", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[("captain", "Капитан"), ("member", "Участник")], + default="member", + max_length=16, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("invited", "Приглашен"), + ("accepted", "Принят"), + ("declined", "Отклонил приглашение"), + ("removed", "Исключен"), + ("left", "Покинул команду"), + ], + default="invited", + max_length=16, + ), + ), + ("joined_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "invited_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invited_application_team_members", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="partner_programs.team", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="application_team_memberships", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Участник команды заявки", + "verbose_name_plural": "Участники команд заявок", + }, + ), + migrations.AddField( + model_name="team", + name="application", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="team", + to="partner_programs.application", + ), + ), + migrations.AddField( + model_name="team", + name="captain", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="captained_application_teams", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.UniqueConstraint( + fields=("team", "user"), name="uniq_team_member_user" + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.UniqueConstraint( + condition=models.Q(("role", "captain"), ("status", "accepted")), + fields=("team",), + name="uniq_accepted_captain_team", + ), + ), + migrations.AddConstraint( + model_name="teammember", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("role", "captain"), _negated=True), + ("status", "accepted"), + _connector="OR", + ), + name="captain_team_member_is_accepted", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index a0b035e6..e0631586 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -177,12 +177,17 @@ def is_project_submission_open(self) -> bool: class Application(models.Model): - """ - MVP application model for participation in a partner program. + """Заявка пользователя на участие в партнерской программе.""" - Team applications are intentionally deferred until the Team model exists. - For now, an application is individual and must have `user`. - """ + PARTICIPATION_MODE_UNDECIDED = "undecided" + PARTICIPATION_MODE_INDIVIDUAL = "individual" + PARTICIPATION_MODE_TEAM = "team" + + PARTICIPATION_MODE_CHOICES = ( + (PARTICIPATION_MODE_UNDECIDED, "Не определен"), + (PARTICIPATION_MODE_INDIVIDUAL, "Индивидуально"), + (PARTICIPATION_MODE_TEAM, "В команде"), + ) STATUS_DRAFT = "draft" STATUS_SUBMITTED = "submitted" @@ -223,6 +228,13 @@ class Application(models.Model): on_delete=models.PROTECT, related_name="created_program_applications", ) + # Временный default сохраняет совместимость с API и frontend, которые пока + # не передают формат участия. `undecided` станет default вместе с wizard. + participation_mode = models.CharField( + max_length=16, + choices=PARTICIPATION_MODE_CHOICES, + default=PARTICIPATION_MODE_INDIVIDUAL, + ) status = models.CharField( max_length=16, choices=STATUS_CHOICES, @@ -250,7 +262,7 @@ def clean(self): if not self.user_id: errors["user"] = ( - "User is required for MVP applications until Team is implemented." + "User is required as the application owner and team captain in MVP." ) if self.user_id and self.program_id and self.status in self.ACTIVE_STATUSES: @@ -294,6 +306,174 @@ def __str__(self): ) +class Team(models.Model): + """Команда конкретной заявки, независимая от состава связанного Project.""" + + application = models.OneToOneField( + Application, + on_delete=models.CASCADE, + related_name="team", + ) + name = models.CharField(max_length=255, blank=True, default="") + captain = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="captained_application_teams", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + """Проверяет формат заявки и единого владельца командного MVP.""" + super().clean() + + errors = {} + if self.application_id: + application = self.application + if application.participation_mode != Application.PARTICIPATION_MODE_TEAM: + errors["application"] = ( + "Команду можно создать только для заявки с командным форматом." + ) + if self.captain_id and self.captain_id != application.user_id: + errors["captain"] = ( + "Капитан команды должен совпадать с владельцем заявки." + ) + + # Полный invariant намеренно не требует captain TeamMember: при первом + # сохранении Team связанная запись участника еще не может существовать. + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Команда заявки" + verbose_name_plural = "Команды заявок" + + def __str__(self): + return f"Team<{self.pk}> application={self.application_id} name={self.name}" + + +class TeamMember(models.Model): + """Членство пользователя в команде конкретной заявки.""" + + ROLE_CAPTAIN = "captain" + ROLE_MEMBER = "member" + + ROLE_CHOICES = ( + (ROLE_CAPTAIN, "Капитан"), + (ROLE_MEMBER, "Участник"), + ) + + STATUS_INVITED = "invited" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REMOVED = "removed" + STATUS_LEFT = "left" + + STATUS_CHOICES = ( + (STATUS_INVITED, "Приглашен"), + (STATUS_ACCEPTED, "Принят"), + (STATUS_DECLINED, "Отклонил приглашение"), + (STATUS_REMOVED, "Исключен"), + (STATUS_LEFT, "Покинул команду"), + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="members", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="application_team_memberships", + ) + role = models.CharField( + max_length=16, + choices=ROLE_CHOICES, + default=ROLE_MEMBER, + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_INVITED, + ) + invited_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="invited_application_team_members", + null=True, + blank=True, + ) + joined_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + """Проверяет роль капитана и фиксирует первое принятие в команду.""" + super().clean() + + errors = {} + if self.role == self.ROLE_CAPTAIN: + if self.status != self.STATUS_ACCEPTED: + errors["status"] = "Капитан должен быть принятым участником команды." + if self.team_id and self.user_id != self.team.captain_id: + errors["user"] = "Капитан-участник должен совпадать с капитаном команды." + + if self.status == self.STATUS_ACCEPTED and self.joined_at is None: + self.joined_at = timezone.now() + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + joined_at_was_missing = self.joined_at is None + self.full_clean() + if ( + joined_at_was_missing + and self.joined_at is not None + and kwargs.get("update_fields") is not None + ): + kwargs["update_fields"] = set(kwargs["update_fields"]) | {"joined_at"} + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Участник команды заявки" + verbose_name_plural = "Участники команд заявок" + constraints = [ + models.UniqueConstraint( + fields=["team", "user"], + name="uniq_team_member_user", + ), + models.UniqueConstraint( + fields=["team"], + condition=models.Q( + role="captain", + status="accepted", + ), + name="uniq_accepted_captain_team", + ), + models.CheckConstraint( + check=( + ~models.Q(role="captain") + | models.Q(status="accepted") + ), + name="captain_team_member_is_accepted", + ), + ] + # Конфликт участий между командами и индивидуальными заявками проходит + # через несколько таблиц и требует отдельного транзакционного service. + + def __str__(self): + return ( + f"TeamMember<{self.pk}> team={self.team_id} " + f"user={self.user_id} role={self.role} status={self.status}" + ) + + class Submission(models.Model): """Versioned solution submitted for an application and program stage.""" diff --git a/partner_programs/tests/test_application_team_models.py b/partner_programs/tests/test_application_team_models.py new file mode 100644 index 00000000..c3ee8ff8 --- /dev/null +++ b/partner_programs/tests/test_application_team_models.py @@ -0,0 +1,336 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, Team, TeamMember +from partner_programs.tests.helpers import create_partner_program, create_user + + +class ApplicationTeamModelTests(TestCase): + def setUp(self): + self.program = create_partner_program() + self.captain = create_user(prefix="team-captain") + + def create_application(self, *, participation_mode=None, user=None): + owner = user or self.captain + data = { + "program": self.program, + "user": owner, + "created_by": owner, + } + if participation_mode is not None: + data["participation_mode"] = participation_mode + return Application.objects.create(**data) + + def create_team(self, *, application=None, captain=None, name="Team"): + return Team.objects.create( + application=application + or self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ), + captain=captain or self.captain, + name=name, + ) + + def test_participation_mode_defaults_to_individual(self): + application = self.create_application() + + self.assertEqual( + application.participation_mode, + Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + def test_participation_mode_choices_contain_expected_values(self): + self.assertEqual( + {value for value, _label in Application.PARTICIPATION_MODE_CHOICES}, + {"undecided", "individual", "team"}, + ) + + def test_existing_individual_application_flow_still_works(self): + application = self.create_application() + + self.assertEqual(application.user, self.captain) + self.assertEqual(application.created_by, self.captain) + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertFalse(hasattr(application, "team")) + + def test_team_can_be_created_for_team_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + + team = self.create_team(application=application) + + self.assertEqual(team.application, application) + self.assertEqual(team.captain, self.captain) + + def test_team_cannot_be_created_for_individual_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL + ) + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application) + + self.assertIn("application", error.exception.message_dict) + + def test_team_cannot_be_created_for_undecided_application(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_UNDECIDED + ) + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application) + + self.assertIn("application", error.exception.message_dict) + + def test_team_captain_must_match_application_user(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + another_user = create_user(prefix="another-captain") + + with self.assertRaises(ValidationError) as error: + self.create_team(application=application, captain=another_user) + + self.assertIn("captain", error.exception.message_dict) + + def test_application_can_have_only_one_team(self): + application = self.create_application( + participation_mode=Application.PARTICIPATION_MODE_TEAM + ) + self.create_team(application=application) + + with self.assertRaises(ValidationError): + self.create_team(application=application, name="Another team") + + def test_accepted_captain_member_can_be_created(self): + team = self.create_team() + + member = TeamMember.objects.create( + team=team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + + self.assertEqual(member.user, team.captain) + self.assertIsNone(member.invited_by) + self.assertIsNotNone(member.joined_at) + + def test_captain_member_must_match_team_captain(self): + team = self.create_team() + another_user = create_user(prefix="captain-member") + + with self.assertRaises(ValidationError) as error: + TeamMember.objects.create( + team=team, + user=another_user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + + self.assertIn("user", error.exception.message_dict) + + def test_captain_member_must_be_accepted(self): + team = self.create_team() + + for member_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + with self.subTest(status=member_status): + with self.assertRaises(ValidationError) as error: + TeamMember.objects.create( + team=team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=member_status, + ) + + self.assertIn("status", error.exception.message_dict) + + def test_user_cannot_have_two_memberships_in_same_team(self): + team = self.create_team() + user = create_user(prefix="duplicate-member") + TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + with self.assertRaises(ValidationError): + TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + + def test_regular_member_can_be_invited(self): + team = self.create_team() + user = create_user(prefix="invited-member") + + member = TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + invited_by=self.captain, + ) + + self.assertEqual(member.status, TeamMember.STATUS_INVITED) + self.assertEqual(member.invited_by, self.captain) + self.assertIsNone(member.joined_at) + + def test_regular_member_can_become_accepted(self): + team = self.create_team() + user = create_user(prefix="accepted-member") + member = TeamMember.objects.create( + team=team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + member.status = TeamMember.STATUS_ACCEPTED + member.save() + member.refresh_from_db() + + self.assertEqual(member.status, TeamMember.STATUS_ACCEPTED) + self.assertIsNotNone(member.joined_at) + + def test_explicit_joined_at_is_preserved(self): + team = self.create_team() + joined_at = timezone.now() - timezone.timedelta(days=1) + + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="joined-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + joined_at=joined_at, + ) + + self.assertEqual(member.joined_at, joined_at) + + def test_removed_or_left_member_keeps_joined_at(self): + team = self.create_team() + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="former-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + joined_at = member.joined_at + + for member_status in ( + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + member.status = member_status + member.save() + member.refresh_from_db() + + self.assertEqual(member.joined_at, joined_at) + + def test_deleting_team_deletes_members(self): + team = self.create_team() + member = TeamMember.objects.create( + team=team, + user=create_user(prefix="deleted-team-member"), + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + team.delete() + + self.assertFalse(TeamMember.objects.filter(pk=member.pk).exists()) + + +class ApplicationTeamDatabaseConstraintTests(TransactionTestCase): + def setUp(self): + self.program = create_partner_program() + self.captain = create_user(prefix="constraint-captain") + self.application = Application.objects.create( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ) + self.team = Team.objects.create( + application=self.application, + captain=self.captain, + name="Constraint team", + ) + + def test_one_to_one_application_constraint_is_enforced_by_database(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + Team.objects.bulk_create( + [ + Team( + application=self.application, + captain=self.captain, + name="Duplicate team", + ) + ] + ) + + def test_team_user_constraint_is_enforced_by_database(self): + user = create_user(prefix="constraint-member") + TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_INVITED, + ) + + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ) + ] + ) + + def test_only_one_accepted_captain_constraint_is_enforced_by_database(self): + TeamMember.objects.create( + team=self.team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + another_user = create_user(prefix="second-constraint-captain") + + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=another_user, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ] + ) + + def test_captain_status_check_constraint_is_enforced_by_database(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + TeamMember.objects.bulk_create( + [ + TeamMember( + team=self.team, + user=self.captain, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_INVITED, + ) + ] + )