From 2b25546e70b1cad049a1ffcc42d1fa23d29e2595 Mon Sep 17 00:00:00 2001 From: Fedor Date: Tue, 21 Jul 2026 18:58:15 +0300 Subject: [PATCH] Add team permissions and API --- docs/application-team-model.md | 22 +- docs/application-team-service.md | 28 +- docs/case-championship-domain.md | 69 ++- docs/program-participation-policy.md | 6 +- docs/team-permissions-api.md | 124 ++++ partner_programs/application_urls.py | 26 + partner_programs/applications_views.py | 80 ++- partner_programs/permissions.py | 78 ++- partner_programs/serializers/__init__.py | 14 + partner_programs/serializers/applications.py | 25 +- partner_programs/serializers/teams.py | 198 +++++++ partner_programs/services/__init__.py | 24 + partner_programs/services/application_team.py | 17 +- partner_programs/services/team_management.py | 260 +++++++++ partner_programs/submission_views.py | 119 +++- partner_programs/team_views.py | 172 ++++++ partner_programs/tests/test_team_api.py | 528 ++++++++++++++++++ .../tests/test_team_management_service.py | 415 ++++++++++++++ .../tests/test_team_permissions.py | 115 ++++ partner_programs/throttling.py | 10 + 20 files changed, 2233 insertions(+), 97 deletions(-) create mode 100644 docs/team-permissions-api.md create mode 100644 partner_programs/serializers/teams.py create mode 100644 partner_programs/services/team_management.py create mode 100644 partner_programs/team_views.py create mode 100644 partner_programs/tests/test_team_api.py create mode 100644 partner_programs/tests/test_team_management_service.py create mode 100644 partner_programs/tests/test_team_permissions.py create mode 100644 partner_programs/throttling.py diff --git a/docs/application-team-model.md b/docs/application-team-model.md index 275a316a..bec4fb19 100644 --- a/docs/application-team-model.md +++ b/docs/application-team-model.md @@ -8,9 +8,10 @@ - `Team` — команда, собранная только для этой заявки; - `TeamMember` — членство пользователя в команде заявки. -Публичного Team API пока нет. Транзакционный Application/Team service создает -командный draft вместе с Team и accepted-капитаном и проверяет полный invariant -перед submit. Состав команды через публичный API пока не редактируется. +Транзакционный Application/Team service создает командный draft вместе с Team и +accepted-капитаном и проверяет полный invariant перед submit. Публичный Team API +дает ролевой read-доступ, rename/leave/remove/transfer, но намеренно не добавляет +нового участника без будущего TeamInvite. ## Participation mode @@ -22,8 +23,8 @@ | `individual` | Индивидуальная заявка | | `team` | Командная заявка | -Runtime default временно равен `individual`: существующие API и frontend не -передают новое поле, поэтому все текущие и исторические Application продолжают +Runtime default временно равен `individual`: API принимает поле, но старые +клиенты его не передают, поэтому текущие и исторические Application продолжают работать как индивидуальные. `undecided` должен стать default одновременно с wizard и запретом submit заявки без выбранного формата. @@ -46,8 +47,8 @@ Team относится к Application, потому что состав ком переиспользуется как TeamMember. В первом MVP `Team.captain` обязан совпадать с `Application.user`. -`Team.status`, invite code, token и настройки размера команды не добавлены: -редактируемость будущего Team flow должна выводиться из `Application.status`. +`Team.status`, invite code и token не добавлены. Размеры хранятся в Program +policy, а редактируемость Team выводится из `Application.status`. ## TeamMember @@ -118,15 +119,14 @@ Domain service выполняет последовательность атом Клиенты используют существующий Application create endpoint с `participation_mode=team` и необязательным `team_name`. При ошибке service -откатывает все три шага. Публичных Team serializers/views/URLs и управления -участниками по-прежнему нет. +откатывает все три шага. После создания детали доступны через +`GET/PATCH /applications//team/`; остальные операции описаны в +`docs/team-permissions-api.md`. ## Вне текущего MVP Следующие PR должны добавить: -- Team permissions и публичный Team API; -- блокировку состава по Application status; - TeamInvite, accept/decline/revoke/expire; - email и внутренние уведомления; - frontend wizard и вкладку команды. diff --git a/docs/application-team-service.md b/docs/application-team-service.md index 07efa7ba..561ef689 100644 --- a/docs/application-team-service.md +++ b/docs/application-team-service.md @@ -13,6 +13,11 @@ Service предоставляет четыре публичные операц - `validate_team_invariants()`; - `submit_application()`. +Управление уже созданной Team вынесено в соседний +`partner_programs.services.team_management`: `rename_team()`, `leave_team()`, +`remove_team_member()` и `transfer_team_captain()`. Оба service используют одну +проверку cross-table конфликта под блокировкой Program. + ## Доменные ошибки Все ожидаемые отказы наследуются от `ApplicationTeamServiceError` и содержат @@ -48,8 +53,8 @@ frontend сохраняет прежний формат. ## Смена формата -`change_application_participation_mode()` доступен только владельцу draft до -application deadline: +`change_application_participation_mode()` доступен владельцу или staff для +draft до application deadline: - `undecided → individual` меняет только поле; - `undecided/individual → team` создает Team и капитана; @@ -80,9 +85,9 @@ PATCH Application вызывает эту операцию до сохранен partial unique constraint дополнительно защищает две собственные активные Application. -Cross-table invariant нельзя выразить обычным UniqueConstraint. Прямые записи -через admin/model и будущий Team API должны использовать тот же service или -отдельную транзакционную операцию принятия участника. +Cross-table invariant нельзя выразить обычным UniqueConstraint. Team transfer +использует ту же проверку под общей блокировкой Program; прямые записи через +admin/model и будущий accept TeamInvite также обязаны проходить domain service. ## Полный Team invariant @@ -112,16 +117,18 @@ Cross-table invariant нельзя выразить обычным UniqueConstra ## API contract -Существующие routes и throttle scope не изменены: +Application routes сохраняют прежние поля и действия: - `POST /programs//applications/` принимает необязательные `participation_mode` и write-only `team_name`; - отсутствие mode означает `individual`; -- response дополнен `participation_mode`, но не раскрывает TeamMember; +- response содержит `participation_mode` и компактный read-only `team` summary, + но не раскрывает полный список TeamMember; - `PATCH /applications//` проводит mode/team name через service; - `POST /applications//submit/` вызывает транзакционный submit service. -Публичного Team CRUD, управления участниками и TeamInvite в этом изменении нет. +Публичный Team API отдельно описан в `docs/team-permissions-api.md`. Прямого +добавления участника и TeamInvite по-прежнему нет. ## Ограничения concurrency и MVP @@ -130,5 +137,6 @@ SQLite не реализует полноценный `select_for_update`, по тест проверяет устойчивую последовательную конфликтную операцию без threading. Service пока не управляет приглашениями/принятием участников и не блокирует -прямое редактирование моделей через admin. Нет Team permissions для обычных -members, передачи капитанства, returned Application и organizer review. +прямое редактирование моделей через admin. Team permissions, read access и +передача капитанства реализованы отдельным service/API; returned Application и +organizer review отсутствуют. diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index 8c7e6eed..716e304b 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -99,7 +99,8 @@ legacy `Project`. **Текущее состояние.** Реализована как `partner_programs.Application`. `user` и `created_by` обязательны для текущего MVP. Поле `participation_mode` поддерживает `undecided/individual/team`, но runtime default временно остается -`individual`, а существующий API поле не принимает и не валидирует. +`individual`. Application create/PATCH принимают поле и проводят изменение +через транзакционный domain service. ### Team @@ -116,8 +117,9 @@ collaborators проекта и состояние email-приглашений. **Текущее состояние.** Реализована как `partner_programs.Team` с one-to-one Application, названием, капитаном и timestamps. Транзакционный service создает -Team вместе с accepted-капитаном и проверяет invariant перед submit; публичного -Team API пока нет. `projects.Collaborator` не является заменой: он связан с +Team вместе с accepted-капитаном и проверяет invariant перед submit. Публичный +API дает ролевое чтение, rename/leave/remove/transfer без прямого добавления +участника. `projects.Collaborator` не является заменой: он связан с долгоживущим `Project`, а не с заявкой на одну программу. ### TeamMember @@ -135,8 +137,8 @@ Team API пока нет. `projects.Collaborator` не является заме **Текущее состояние.** Реализована как `partner_programs.TeamMember` с ролями, пятью статусами, `invited_by` и `joined_at`. `projects.Collaborator` продолжает -хранить только участников проекта. Механизма приглашений и cross-Application -проверок участия еще нет. +хранить только участников проекта. Cross-Application конфликт проверяется при +create/submit/transfer; публичного механизма приглашений еще нет. ### TeamInvite @@ -551,7 +553,7 @@ Legacy-связи сохраняются отдельно: Текущие ограничения: - нет проверки дедлайна; -- нет manager/expert/team permissions; +- accepted TeamMember и manager имеют read-only доступ; expert permissions нет; - нет действий `return` и `finalize`; - Application status повторно проверяется при создании, но не при PATCH, submit или cancel существующей Submission; @@ -603,10 +605,10 @@ Registration. Текущие различия: -- новые Application/Submission endpoints признают владельца по - `Application.user` и staff/superuser; -- `PartnerProgram.managers` не получают доступ к новым сущностям автоматически; -- участника команды и капитана пока нет; +- капитан совпадает с `Application.user` и сохраняет owner mutation-права; +- accepted TeamMember и `PartnerProgram.managers` читают Application, Team и + Submission, но не выполняют participant mutations; +- приглашенные и исторические TeamMember не получают read-доступ; - эксперт работает только с legacy Project через `project_rates`; - staff может выполнять submit/withdraw Application и создавать/изменять Submission, что является административной возможностью текущего MVP, а не @@ -621,23 +623,23 @@ Registration. | 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, 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 API | create/my/detail/patch/submit/withdraw, mode/team service, Registration/deadline/policy/conflict checks, idempotency и scoped throttle | Accepted member/manager read-only | review endpoints | Старый create без mode остается individual; my application учитывает accepted TeamMember | | 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 | +| Submission API | list/create/detail/patch/submit/cancel, owner/staff mutations, accepted member/manager read-only, version allocation, throttling | Нет expert 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 и атомарный 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 | +| Team | One-to-one Application, name, captain, timestamps, validation/admin, creation/invariant/management services и public API | Нет add member | TeamInvite integration | Mutation только draft до application deadline | +| TeamMember | Roles/statuses, invited_by, joined_at, constraints, validation, admin, read/leave/remove/transfer | invited — только модельная заготовка | Invite accept/decline API | Не переиспользует 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 с 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 | +| Permissions | Единые owner/captain/accepted member/manager/staff helpers для Application/Team/Submission | Staff имеет расширенный доступ | Expert/review permissions | Историческое membership не дает read-доступ | | 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 покрыты отдельно | +| Tests | Model/service/permission/API tests Application/Team/TeamMember/Submission и regression legacy flow | Нет invite end-to-end flow | Invite/evaluation/result tests нового flow | Team transfer проверяется вместе с rollback и сменой ownership | ### Актуальные domain endpoints @@ -646,11 +648,15 @@ Registration. | Program | `GET /programs/`, `GET /programs//` | Список и детали активности | | Registration | `POST /programs//register/` | Создать PartnerProgramUserProfile текущего пользователя | | Registration legacy | `POST /programs//register_new/` | Создать/найти пользователя и зарегистрировать из внешней формы | -| Application | `GET /programs//applications/my/` | Вернуть активную или последнюю собственную Application; 404, если ее нет | +| Application | `GET /programs//applications/my/` | Вернуть собственную либо accepted-командную Application; 404, если ее нет | | Application | `POST /programs//applications/` | Создать draft или вернуть существующую активную Application | | Application | `GET/PATCH /applications//` | Прочитать или изменить draft | | Application | `POST /applications//submit/` | Отправить draft | | Application | `POST /applications//withdraw/` | Отозвать допустимую Application | +| Team | `GET/PATCH /applications//team/` | Прочитать Team или изменить ее название | +| Team | `POST /applications//team/leave/` | Покинуть draft Team обычному accepted member | +| Team | `POST /applications//team/members//remove/` | Исключить обычного member капитаном/staff | +| Team | `POST /applications//team/transfer-captain/` | Атомарно передать капитанство и ownership Application | | Submission | `GET/POST /applications//submissions/` | Список версий или новый draft Submission | | Submission | `GET/PATCH /submissions//` | Прочитать или изменить draft/returned | | Submission | `POST /submissions//submit/` | Отправить Submission | @@ -684,17 +690,17 @@ Registration. | Требование | Текущее состояние | Требуемое изменение | Приоритет | Рекомендуемый PR | |---|---|---|---|---| -| Явный формат участия | Поле и безопасный default `individual` реализованы | Подключить API/UI и позднее default `undecided` | P0 | Application participation wizard/API | -| Team и TeamMember | Модели, admin, constraints и транзакционный service реализованы | Добавить permissions и публичный API | P0 | Team permissions/API | +| Явный формат участия | Поле/API и безопасный default `individual` реализованы | Подключить UI и позднее default `undecided` | P0 | Application participation wizard | +| Team и TeamMember | Модели, admin, constraints, services, permissions и public API реализованы | Добавить TeamInvite | P1 | TeamInvite model/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 | +| Captain-only actions | Team-aware permissions и transfer реализованы | Подключить invite/review actions к тем же helpers | P1 | TeamInvite/review API | +| Team API | Read/rename/leave/remove/transfer реализованы | Добавить invite lifecycle без direct add member | P1 | TeamInvite 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 | -| Application deadlines | Отдельное поле и helper Program реализованы, API их не проверяет | Проверять create/edit/submit/withdraw без fallback на legacy deadlines | P0 | Application eligibility and lifecycle | -| Submission deadlines и роли | Новый API не проверяет сроки, manager/expert/team | Добавить policy и единый permission layer | P1 | Submission lifecycle hardening | +| Application deadlines | Create/mode/submit и Team mutations проверяют отдельный deadline | Решить policy для form-only PATCH/withdraw | P1 | Application lifecycle hardening | +| Submission deadlines и роли | Team/manager read-only реализован, deadline/expert отсутствуют | Добавить deadline и expert policy | P1 | Submission lifecycle hardening | | Return/finalize Submission | Статусы есть, действий нет | Организаторские endpoints и audit fields/reasons | P1 | Submission review API | | Semantics версий Submission | Несколько записей и auto-next уже есть | Определить current/final/evaluated version и поведение after return | P1 | Submission version workflow | | Предзаполнение из Project | Только nullable FK | Явный mapping/copy в Application.form_data | P1 | Application project prefill | @@ -715,9 +721,9 @@ Registration. 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/ссылке. +4. **`feature/team-permissions-api` — реализовано.** Добавлены + owner/captain/member/manager/staff permissions, чтение Team, rename, + leave/remove/transfer и read-only доступ к Application/Submission. 5. **Application review API.** Добавить `returned`, return, approve, reject и cancel с reason и timestamps; не смешивать с admin-редактированием. 6. **TeamInvite model and API.** Добавить platform/email/link invites, @@ -749,8 +755,9 @@ endpoints без отдельной миграционной задачи. Ветка: `feature/application-team-model`. Статус исторического PR: базовый слой данных реализован без service. В -последующем PR транзакционный creation/invariant service подключен к -Application create/PATCH/submit; отдельный Team API по-прежнему отсутствует. +последующих PR транзакционный creation/invariant service подключен к +Application create/PATCH/submit, затем добавлен отдельный Team permissions/API +без TeamInvite. ### Точный scope @@ -805,6 +812,6 @@ Application create/PATCH/submit; отдельный Team API по-прежнем legacy project submission; - deploy, settings, Docker, nginx и workflows. -Такой scope дал самостоятельный мигрируемый слой данных. Последующий domain -service уже обеспечивает атомарное создание и submit-invariant до появления -публичного Team API. +Такой scope дал самостоятельный мигрируемый слой данных. Последующие PR +добавили атомарное создание/submit-invariant и публичный Team permissions/API, +не меняя историческую миграцию моделей. diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md index c9262102..90ca22b1 100644 --- a/docs/program-participation-policy.md +++ b/docs/program-participation-policy.md @@ -82,6 +82,6 @@ Application create, смена participation mode и submit теперь при проверяют Registration, deadline и конфликты участия. Legacy registration/project flow не изменен. -Публичного Team API пока нет. Следующий PR должен добавить Team permissions и -управление составом так, чтобы операции принятия участников использовали те же -cross-table проверки без изменения legacy-дедлайнов. +Публичный Team API дает ролевое чтение и операции rename/leave/remove/transfer. +Будущий TeamInvite/accept service должен использовать те же cross-table +проверки без изменения legacy-дедлайнов. diff --git a/docs/team-permissions-api.md b/docs/team-permissions-api.md new file mode 100644 index 00000000..d19ae5d3 --- /dev/null +++ b/docs/team-permissions-api.md @@ -0,0 +1,124 @@ +# Team Permissions and API + +## Назначение + +Публичный Team API дает безопасный read-доступ к командной заявке и базовые +операции управления составом. `Team` относится к `Application`, а не к +`Project`; `projects.Collaborator` и legacy `invites.Invite` не участвуют в +этом flow. + +Новых участников нельзя добавить напрямую по `user_id`. Согласие пользователя +будет оформляться отдельной сущностью `TeamInvite`; этот API только читает +состав, меняет название, фиксирует выход/удаление и передает капитанство. + +## Роли и права + +| Роль | Application | Team | Submission | Mutation Team | +|---|---|---|---|---| +| Владелец individual Application | read/write/submit/withdraw | — | read/write | — | +| Капитан Team | read/write/submit/withdraw | read | read/write | rename/remove/transfer | +| Accepted member | read-only | read | read-only | leave | +| Manager PartnerProgram | read-only | read | read-only | нет | +| Staff/superuser | текущий административный доступ | read | текущий административный доступ | rename/remove/transfer | + +Только `TeamMember.status=accepted` дает участнику read-доступ. `invited`, +`declined`, `removed` и `left` являются историей состава и не открывают +Application, Team или Submission. + +## Endpoints + +- `GET /applications//team/` — детали Team и состав; +- `PATCH /applications//team/` — изменить только `name`; +- `POST /applications//team/leave/` — выйти обычному member; +- `POST /applications//team/members//remove/` — + перевести member в `removed`; +- `POST /applications//team/transfer-captain/` — передать роль, + body: `{"member_id": }`. + +Team response содержит безопасный профиль пользователя (`id`, +`display_name`, `avatar`) без email, телефона и закрытых полей. Состав +сортируется: капитан, accepted members, исторические статусы, затем дата/id. +Также возвращаются размерные ограничения Program, роль текущего пользователя и +флаги `can_edit`, `can_manage_members`, `can_leave`. + +Application response дополнен компактным read-only `team` summary: `id`, +`name`, `captain_id`, `accepted_members_count`, `current_user_role`. Для +individual/undecided значение равно `null`; полный состав остается только в +Team detail. + +## Leave, remove и transfer + +Все mutation разрешены только для `Application.status=draft` и до +`datetime_application_ends`; отдельный Application deadline не имеет fallback +на legacy-даты. После submit состав, капитан и название заблокированы. + +`leave` доступен accepted member с ролью `member`. Статус становится `left`, +строка и `joined_at` сохраняются. Повторный запрос возвращает стабильную +ошибку `team_membership_not_active`. Капитан получает +`captain_transfer_required`. + +`remove` доступен капитану/staff для accepted или invited member. Статус +становится `removed`, `joined_at` не очищается. Капитана удалить нельзя; +исторический статус возвращает `team_member_not_removable`. + +`transfer-captain` требует accepted member той же Team, Registration этой +Program и отсутствие другой активной собственной/командной Application. +Операция блокирует Program, Application, Team и обе TeamMember, затем атомарно: + +1. переводит старого капитана в accepted member; +2. переводит target member в accepted captain; +3. меняет `Team.captain`; +4. меняет `Application.user`. + +`Application.created_by` и `Application.project` не меняются. Старый капитан +сохраняет read-доступ как accepted member, но теряет write/submit права. + +## Application и Submission read access + +`GET /programs//applications/my/` ищет в порядке: + +1. собственную активную Application; +2. активную Application accepted-команды; +3. последнюю собственную/командную терминальную Application. + +Accepted member и manager могут читать Application detail, список Submission и +Submission detail. PATCH/create/submit/cancel/withdraw остаются owner/captain +или staff operations. Постороннему пользователю объекты не раскрываются. + +## Доменные ошибки + +Team management service использует стабильные коды: + +- `team_permission_denied`; +- `captain_transfer_required`; +- `team_membership_not_active`; +- `team_member_not_found`; +- `team_member_not_removable`; +- `captain_transfer_target_invalid`; +- общие `application_not_editable`, `application_deadline_passed`, + `team_member_registration_missing`, `active_application_conflict`, + `captain_member_missing`, `captain_mismatch`. + +Permission boundary возвращает 403, скрытый объект — 404, нарушение состояния +или invariant — 400. + +## Throttling + +Mutation endpoints имеют независимые scoped buckets: + +- `team_rename`; +- `team_leave`; +- `team_remove_member`; +- `team_transfer_captain`. + +Rate равен `20/min` на scope и пользователя/IP. Throttle локален для Team API: +глобальные DRF settings и существующие application/submission scopes не +изменяются; GET Team отдельным throttle не ограничивается. + +## Ограничения MVP + +В API намеренно отсутствуют прямой add member, TeamInvite, token/link invites, +email, уведомления и поиск пользователей. Manager получает только read-access; +organizer review, approve/reject/return, изменение Submission lifecycle и +expert permissions остаются отдельными PR. Frontend и legacy Project flow этим +изменением не затрагиваются. diff --git a/partner_programs/application_urls.py b/partner_programs/application_urls.py index 2a7dbb4b..52629d80 100644 --- a/partner_programs/application_urls.py +++ b/partner_programs/application_urls.py @@ -6,10 +6,36 @@ ApplicationWithdrawView, ) from partner_programs.submission_views import ApplicationSubmissionListCreateView +from partner_programs.team_views import ( + TeamDetailView, + TeamLeaveView, + TeamMemberRemoveView, + TeamTransferCaptainView, +) app_name = "applications" urlpatterns = [ + path( + "/team/members//remove/", + TeamMemberRemoveView.as_view(), + name="team-member-remove", + ), + path( + "/team/transfer-captain/", + TeamTransferCaptainView.as_view(), + name="team-transfer-captain", + ), + path( + "/team/leave/", + TeamLeaveView.as_view(), + name="team-leave", + ), + path( + "/team/", + TeamDetailView.as_view(), + name="team-detail", + ), path( "/submissions/", ApplicationSubmissionListCreateView.as_view(), diff --git a/partner_programs/applications_views.py b/partner_programs/applications_views.py index 89f915fb..f99af84a 100644 --- a/partner_programs/applications_views.py +++ b/partner_programs/applications_views.py @@ -1,5 +1,6 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status @@ -9,7 +10,8 @@ from rest_framework.views import APIView from core.throttling import PostOnlyScopedRateThrottle -from partner_programs.models import Application, PartnerProgram +from partner_programs.models import Application, PartnerProgram, TeamMember +from partner_programs.permissions import can_edit_application from partner_programs.serializers import ApplicationSerializer from partner_programs.services.application_team import ( ApplicationNotEditableError, @@ -29,10 +31,17 @@ def _application_queryset_for(user): ) if user.is_staff or user.is_superuser: return queryset - return queryset.filter(user=user) + return queryset.filter( + Q(user=user) + | Q( + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() -def _active_application(*, program, user): +def _owned_active_application(*, program, user): return ( Application.objects.select_related( "program", @@ -50,6 +59,27 @@ def _active_application(*, program, user): ) +def _member_active_application(*, program, user): + return ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + "team", + "team__captain", + ) + .filter( + program=program, + status__in=Application.ACTIVE_STATUSES, + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + .order_by("-created_at") + .first() + ) + + def _application_response(application, request, response_status=status.HTTP_200_OK): serializer = ApplicationSerializer(application, context={"request": request}) return Response(serializer.data, status=response_status) @@ -108,7 +138,12 @@ class MyProgramApplicationView(APIView): def get(self, request, program_id): program = get_object_or_404(PartnerProgram, pk=program_id) - application = _active_application(program=program, user=request.user) + application = _owned_active_application(program=program, user=request.user) + if application is None: + application = _member_active_application( + program=program, + user=request.user, + ) if application is None: application = ( Application.objects.select_related( @@ -116,8 +151,18 @@ def get(self, request, program_id): "user", "created_by", "project", + "team", + "team__captain", ) - .filter(program=program, user=request.user) + .filter(program=program) + .filter( + Q(user=request.user) + | Q( + team__members__user=request.user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + ) + .exclude(status__in=Application.ACTIVE_STATUSES) .order_by("-created_at") .first() ) @@ -141,8 +186,8 @@ def get(self, request, application_id): def patch(self, request, application_id): application = self.get_object(request, application_id) - if application.user_id != request.user.pk: - raise PermissionDenied("Only the application owner can update it.") + if not can_edit_application(request.user, application): + raise PermissionDenied("Only the application owner or staff can update it.") serializer = ApplicationSerializer( application, @@ -169,6 +214,10 @@ def patch(self, request, application_id): application = Application.objects.select_for_update().get( pk=application.pk ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can update it." + ) if application.status != Application.STATUS_DRAFT: raise ApplicationNotEditableError( "Изменить можно только черновик заявки." @@ -190,6 +239,8 @@ def post(self, request, application_id): _application_queryset_for(request.user), pk=application_id, ) + if not can_edit_application(request.user, application): + raise PermissionDenied("Only the application owner or staff can submit it.") try: application = submit_application( application=application, @@ -214,10 +265,21 @@ class ApplicationWithdrawView(APIView): def post(self, request, application_id): with transaction.atomic(): - application = get_object_or_404( - _application_queryset_for(request.user).select_for_update(), + visible_application = get_object_or_404( + _application_queryset_for(request.user), pk=application_id, ) + if not can_edit_application(request.user, visible_application): + raise PermissionDenied( + "Only the application owner or staff can withdraw it." + ) + application = Application.objects.select_for_update().get( + pk=visible_application.pk + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can withdraw it." + ) if application.status == Application.STATUS_WITHDRAWN: return _application_response(application, request) if application.status not in self.allowed_statuses: diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index 33c6a28c..fa9bbdd9 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -1,6 +1,82 @@ from rest_framework.permissions import BasePermission -from partner_programs.models import PartnerProgram +from partner_programs.models import Application, PartnerProgram, Submission, Team, TeamMember + + +def _is_authenticated(user) -> bool: + return bool(user and user.is_authenticated) + + +def _is_staff(user) -> bool: + return _is_authenticated(user) and bool(user.is_staff or user.is_superuser) + + +def is_application_owner(user, application: Application) -> bool: + """Проверяет, является ли пользователь текущим владельцем заявки.""" + return _is_authenticated(user) and application.user_id == user.pk + + +def is_team_captain(user, team: Team) -> bool: + """Проверяет, является ли пользователь текущим капитаном команды.""" + return _is_authenticated(user) and team.captain_id == user.pk + + +def is_accepted_team_member(user, team: Team) -> bool: + """Учитывает только активное принятое членство, а не историю состава.""" + if not _is_authenticated(user): + return False + return TeamMember.objects.filter( + team=team, + user=user, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + + +def is_program_manager(user, program: PartnerProgram) -> bool: + """Проверяет membership пользователя в managers конкретной программы.""" + return _is_authenticated(user) and program.is_manager(user) + + +def can_view_application(user, application: Application) -> bool: + """Разрешает чтение владельцу, accepted-команде, менеджеру и staff.""" + if _is_staff(user) or is_application_owner(user, application): + return True + if is_program_manager(user, application.program): + return True + try: + team = application.team + except Team.DoesNotExist: + return False + return is_accepted_team_member(user, team) + + +def can_edit_application(user, application: Application) -> bool: + """Ограничивает изменения владельцем/капитаном и staff.""" + return _is_staff(user) or is_application_owner(user, application) + + +def can_view_team(user, team: Team) -> bool: + """Разрешает чтение accepted-составу, менеджеру программы и staff.""" + return ( + _is_staff(user) + or is_program_manager(user, team.application.program) + or is_accepted_team_member(user, team) + ) + + +def can_manage_team(user, team: Team) -> bool: + """Ограничивает управление текущим капитаном и staff.""" + return _is_staff(user) or is_team_captain(user, team) + + +def can_view_submission(user, submission: Submission) -> bool: + """Наследует read-доступ Submission от связанной Application.""" + return can_view_application(user, submission.application) + + +def can_edit_submission(user, submission: Submission) -> bool: + """Оставляет mutation Submission владельцу заявки и staff.""" + return can_edit_application(user, submission.application) class IsProjectLeader(BasePermission): diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 805f548e..9833df50 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -17,9 +17,18 @@ UserProgramsSerializer, ) from .submissions import SubmissionSerializer +from .teams import ( + ApplicationTeamSummarySerializer, + TeamMemberSerializer, + TeamSerializer, + TeamTransferCaptainSerializer, + TeamUpdateSerializer, + TeamUserSerializer, +) __all__ = [ "ApplicationSerializer", + "ApplicationTeamSummarySerializer", "PartnerProgramBaseSerializerMixin", "PartnerProgramDataSchemaSerializer", "PartnerProgramFieldSerializer", @@ -35,5 +44,10 @@ "ProgramProjectCreateSerializer", "ProgramProjectFilterRequestSerializer", "SubmissionSerializer", + "TeamMemberSerializer", + "TeamSerializer", + "TeamTransferCaptainSerializer", + "TeamUpdateSerializer", + "TeamUserSerializer", "UserProgramsSerializer", ] diff --git a/partner_programs/serializers/applications.py b/partner_programs/serializers/applications.py index 10e2a018..34400456 100644 --- a/partner_programs/serializers/applications.py +++ b/partner_programs/serializers/applications.py @@ -1,10 +1,12 @@ from rest_framework import serializers -from partner_programs.models import Application +from partner_programs.models import Application, Team +from partner_programs.serializers.teams import ApplicationTeamSummarySerializer from projects.models import Project class ApplicationSerializer(serializers.ModelSerializer): + team = serializers.SerializerMethodField() team_name = serializers.CharField( required=False, allow_blank=True, @@ -31,6 +33,7 @@ class ApplicationSerializer(serializers.ModelSerializer): "user", "created_by", "status", + "team", "submitted_at", "approved_at", "rejected_at", @@ -49,6 +52,7 @@ class Meta: "created_by", "status", "participation_mode", + "team", "team_name", "form_data", "project", @@ -78,6 +82,16 @@ class Meta: "participation_mode": {"required": False}, } + def get_team(self, application: Application): + try: + team = application.team + except Team.DoesNotExist: + return None + return ApplicationTeamSummarySerializer( + team, + context=self.context, + ).data + def update(self, instance, validated_data): # Формат и Team изменяет только domain service; serializer сохраняет # остальные редактируемые поля в общей транзакции view. @@ -111,7 +125,14 @@ def validate(self, attrs): if project is not serializers.empty and project is not None: request = self.context.get("request") user = getattr(request, "user", None) - if not user or not user.is_authenticated or project.leader_id != user.pk: + if ( + not user + or not user.is_authenticated + or ( + project.leader_id != user.pk + and not (user.is_staff or user.is_superuser) + ) + ): raise serializers.ValidationError( {"project": "You can only use a project that you lead."} ) diff --git a/partner_programs/serializers/teams.py b/partner_programs/serializers/teams.py new file mode 100644 index 00000000..b7c12aa8 --- /dev/null +++ b/partner_programs/serializers/teams.py @@ -0,0 +1,198 @@ +from rest_framework import serializers + +from partner_programs.models import Application, Team, TeamMember +from partner_programs.permissions import can_manage_team, is_accepted_team_member +from users.models import CustomUser + + +class TeamUserSerializer(serializers.ModelSerializer): + display_name = serializers.SerializerMethodField() + + class Meta: + model = CustomUser + fields = ("id", "display_name", "avatar") + read_only_fields = fields + + def get_display_name(self, user: CustomUser) -> str: + """Возвращает безопасное имя без email и закрытых полей профиля.""" + return user.get_full_name().strip() + + +class TeamMemberSerializer(serializers.ModelSerializer): + user = TeamUserSerializer(read_only=True) + + class Meta: + model = TeamMember + fields = ( + "id", + "user", + "role", + "status", + "joined_at", + "created_at", + ) + read_only_fields = fields + + +def _current_user_role(team: Team, request): + """Не выдает роль по приглашенному или историческому membership.""" + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return None + member = team.members.filter( + user=user, + status=TeamMember.STATUS_ACCEPTED, + ).first() + return member.role if member else None + + +def _team_is_mutable(team: Team) -> bool: + """Учитывает статус заявки и отдельный deadline без legacy fallback.""" + return ( + team.application.status == Application.STATUS_DRAFT + and not team.application.program.is_application_deadline_passed() + ) + + +class ApplicationTeamSummarySerializer(serializers.ModelSerializer): + captain_id = serializers.IntegerField(read_only=True) + accepted_members_count = serializers.SerializerMethodField() + current_user_role = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = ( + "id", + "name", + "captain_id", + "accepted_members_count", + "current_user_role", + ) + read_only_fields = fields + + def get_accepted_members_count(self, team: Team) -> int: + return team.members.filter(status=TeamMember.STATUS_ACCEPTED).count() + + def get_current_user_role(self, team: Team): + return _current_user_role(team, self.context.get("request")) + + +class TeamSerializer(serializers.ModelSerializer): + application_id = serializers.IntegerField(read_only=True) + program_id = serializers.IntegerField(source="application.program_id", read_only=True) + captain = TeamUserSerializer(read_only=True) + members = serializers.SerializerMethodField() + accepted_members_count = serializers.SerializerMethodField() + team_min_size = serializers.IntegerField( + source="application.program.team_min_size", + read_only=True, + allow_null=True, + ) + team_max_size = serializers.IntegerField( + source="application.program.team_max_size", + read_only=True, + allow_null=True, + ) + current_user_role = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + can_manage_members = serializers.SerializerMethodField() + can_leave = serializers.SerializerMethodField() + + class Meta: + model = Team + fields = ( + "id", + "application_id", + "program_id", + "name", + "captain", + "members", + "accepted_members_count", + "team_min_size", + "team_max_size", + "current_user_role", + "can_edit", + "can_manage_members", + "can_leave", + "created_at", + "updated_at", + ) + read_only_fields = fields + + def get_members(self, team: Team): + members = list(team.members.select_related("user").all()) + + def sort_key(member: TeamMember): + if ( + member.role == TeamMember.ROLE_CAPTAIN + and member.status == TeamMember.STATUS_ACCEPTED + ): + rank = 0 + elif member.status == TeamMember.STATUS_ACCEPTED: + rank = 1 + else: + rank = 2 + return rank, member.created_at, member.pk + + members.sort(key=sort_key) + return TeamMemberSerializer(members, many=True).data + + def get_accepted_members_count(self, team: Team) -> int: + return team.members.filter(status=TeamMember.STATUS_ACCEPTED).count() + + def get_current_user_role(self, team: Team): + return _current_user_role(team, self.context.get("request")) + + def get_can_edit(self, team: Team) -> bool: + request = self.context.get("request") + return bool( + request + and can_manage_team(request.user, team) + and _team_is_mutable(team) + ) + + def get_can_manage_members(self, team: Team) -> bool: + return self.get_can_edit(team) + + def get_can_leave(self, team: Team) -> bool: + request = self.context.get("request") + if not request or not _team_is_mutable(team): + return False + user = request.user + if not is_accepted_team_member(user, team): + return False + return team.members.filter( + user=user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + + +class TeamUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, allow_blank=True, trim_whitespace=True) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"name"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs + + +class TeamTransferCaptainSerializer(serializers.Serializer): + member_id = serializers.IntegerField(min_value=1) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"member_id"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index b92c0240..b2d24477 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -16,9 +16,22 @@ TeamSizeInvalidError, change_application_participation_mode, create_or_get_application, + require_no_active_conflict, submit_application, validate_team_invariants, ) +from partner_programs.services.team_management import ( + CaptainTransferRequiredError, + CaptainTransferTargetInvalidError, + TeamManagementPermissionError, + TeamMemberNotFoundError, + TeamMemberNotRemovableError, + TeamMembershipNotActiveError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) from partner_programs.services.exports import ( BASE_COLUMNS, ProgramExportFile, @@ -57,6 +70,8 @@ "BASE_COLUMNS", "CaptainMemberMissingError", "CaptainMismatchError", + "CaptainTransferRequiredError", + "CaptainTransferTargetInvalidError", "ParticipationModeNotAllowedError", "ParticipationModeUndecidedError", "ProgramExportFile", @@ -67,7 +82,11 @@ "ProjectScoreDataPreparer", "RegistrationRequiredError", "TeamHasOtherMembersError", + "TeamManagementPermissionError", + "TeamMemberNotFoundError", + "TeamMemberNotRemovableError", "TeamMemberRegistrationMissingError", + "TeamMembershipNotActiveError", "TeamNotAllowedError", "TeamRequiredError", "TeamSizeInvalidError", @@ -83,9 +102,14 @@ "prepare_project_scores_export_data", "publish_finished_program_projects", "register_user_to_program", + "remove_team_member", + "rename_team", + "require_no_active_conflict", "require_can_apply_project_to_program", "row_dict_for_link", "submit_application", + "transfer_team_captain", "validate_team_invariants", "validate_program_project_filters", + "leave_team", ] diff --git a/partner_programs/services/application_team.py b/partner_programs/services/application_team.py index 465c4640..c00e25f9 100644 --- a/partner_programs/services/application_team.py +++ b/partner_programs/services/application_team.py @@ -178,12 +178,17 @@ def _active_application_ids_for_user( return set(owned_ids).union(membership_ids) -def _require_no_active_conflict( +def require_no_active_conflict( *, program: PartnerProgram, user: User, exclude_application: Application | None = None, ) -> None: + """Запрещает второе активное участие пользователя в одной программе. + + Вызывающий service должен сначала заблокировать строку Program: простого + UniqueConstraint недостаточно для конфликта между Application и TeamMember. + """ conflict_ids = _active_application_ids_for_user(program=program, user=user) if exclude_application is not None: conflict_ids.discard(exclude_application.pk) @@ -281,7 +286,7 @@ def create_or_get_application( .order_by("-created_at") .first() ) - _require_no_active_conflict( + require_no_active_conflict( program=program, user=user, exclude_application=existing_application, @@ -368,7 +373,9 @@ def change_application_participation_mode( with transaction.atomic(): program = _lock_program(application.program) application = _lock_application(application) - if application.user_id != actor.pk: + if application.user_id != actor.pk and not ( + actor.is_staff or actor.is_superuser + ): raise ApplicationNotEditableError( "Только владелец может изменить формат заявки." ) @@ -382,7 +389,7 @@ def change_application_participation_mode( participation_mode=participation_mode, allow_undecided=True, ) - _require_no_active_conflict( + require_no_active_conflict( program=program, user=application.user, exclude_application=application, @@ -504,7 +511,7 @@ def _require_no_team_member_conflicts(application: Application) -> None: user_ids = [application.user_id] for user_id in user_ids: - _require_no_active_conflict( + require_no_active_conflict( program=application.program, user=User.objects.get(pk=user_id), exclude_application=application, diff --git a/partner_programs/services/team_management.py b/partner_programs/services/team_management.py new file mode 100644 index 00000000..73390182 --- /dev/null +++ b/partner_programs/services/team_management.py @@ -0,0 +1,260 @@ +from django.contrib.auth import get_user_model +from django.db import models, transaction + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamMember, +) +from partner_programs.permissions import can_manage_team, is_team_captain +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + CaptainMemberMissingError, + CaptainMismatchError, + TeamMemberRegistrationMissingError, + require_no_active_conflict, +) + +User = get_user_model() + + +class TeamManagementPermissionError(ApplicationTeamServiceError): + code = "team_permission_denied" + default_detail = "Недостаточно прав для управления командой." + default_field = "team" + + +class CaptainTransferRequiredError(ApplicationTeamServiceError): + code = "captain_transfer_required" + default_detail = "Капитан должен сначала передать капитанство." + default_field = "team" + + +class TeamMembershipNotActiveError(ApplicationTeamServiceError): + code = "team_membership_not_active" + default_detail = "Активное членство в команде не найдено." + default_field = "team" + + +class TeamMemberNotFoundError(ApplicationTeamServiceError): + code = "team_member_not_found" + default_detail = "Участник этой команды не найден." + default_field = "member_id" + + +class TeamMemberNotRemovableError(ApplicationTeamServiceError): + code = "team_member_not_removable" + default_detail = "Участника нельзя удалить в текущем состоянии." + default_field = "member_id" + + +class CaptainTransferTargetInvalidError(ApplicationTeamServiceError): + code = "captain_transfer_target_invalid" + default_detail = "Капитанство можно передать принятому участнику команды." + default_field = "member_id" + + +def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: + """Блокирует Program → Application → Team в едином порядке операций.""" + program_id = team.application.program_id + application_id = team.application_id + team_id = team.pk + + program = PartnerProgram.objects.select_for_update().get(pk=program_id) + application = ( + Application.objects.select_for_update() + .select_related("program", "user", "created_by", "project") + .get(pk=application_id) + ) + locked_team = ( + Team.objects.select_for_update() + .select_related("application", "application__program", "captain") + .get(pk=team_id) + ) + return program, application, locked_team + + +def _require_mutable_team( + *, + application: Application, + program: PartnerProgram, +) -> None: + """Разрешает изменение состава только draft-команде до дедлайна.""" + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Состав команды можно менять только в черновике заявки." + ) + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def rename_team(*, team: Team, actor: User, name: str) -> Team: + """Атомарно меняет название draft-команды до дедлайна. + + Операция доступна только текущему капитану и staff. Остальные поля Team + сервис не принимает и не изменяет. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + team.name = name + team.save(update_fields=["name", "updated_at"]) + return team + + +def leave_team(*, team: Team, actor: User) -> TeamMember: + """Фиксирует выход accepted-участника без удаления истории членства. + + Повторный leave возвращает стабильную team_membership_not_active. Капитан + обязан сначала передать роль; joined_at при выходе намеренно сохраняется. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if is_team_captain(actor, team): + raise CaptainTransferRequiredError() + + member = ( + TeamMember.objects.select_for_update() + .filter(team=team, user=actor) + .first() + ) + if ( + member is None + or member.role != TeamMember.ROLE_MEMBER + or member.status != TeamMember.STATUS_ACCEPTED + ): + raise TeamMembershipNotActiveError() + + _require_mutable_team(application=application, program=program) + member.status = TeamMember.STATUS_LEFT + member.save(update_fields=["status", "updated_at"]) + return member + + +def remove_team_member(*, team: Team, actor: User, member_id: int) -> TeamMember: + """Переводит обычного accepted/invited-участника в removed. + + Запись и joined_at сохраняются как история состава. Исторические статусы + возвращают стабильную team_member_not_removable вместо удаления строки. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + member = ( + TeamMember.objects.select_for_update() + .filter(team=team, pk=member_id) + .first() + ) + if member is None: + raise TeamMemberNotFoundError() + if ( + member.role == TeamMember.ROLE_CAPTAIN + or member.user_id == team.captain_id + ): + raise CaptainTransferRequiredError( + "Капитана нельзя удалить из команды без передачи капитанства." + ) + if member.status not in { + TeamMember.STATUS_ACCEPTED, + TeamMember.STATUS_INVITED, + }: + raise TeamMemberNotRemovableError() + + member.status = TeamMember.STATUS_REMOVED + member.save(update_fields=["status", "updated_at"]) + return member + + +def transfer_team_captain( + *, + team: Team, + actor: User, + member_id: int, +) -> Team: + """Атомарно передает капитанство accepted-участнику draft-команды. + + После общей блокировки Program блокируются Application, Team и обе записи + TeamMember. Старый капитан сначала становится member, чтобы условная + уникальность капитана не нарушилась в промежуточном состоянии. Затем вместе + обновляются Team.captain и Application.user; Application.created_by и Project + намеренно остаются без изменений. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamManagementPermissionError() + _require_mutable_team(application=application, program=program) + + locked_members = list( + TeamMember.objects.select_for_update() + .select_related("user") + .filter(team=team) + .filter( + models.Q(pk=member_id) + | models.Q( + user_id=team.captain_id, + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ) + ) + .order_by("pk") + ) + target = next((item for item in locked_members if item.pk == member_id), None) + captain_members = [ + item + for item in locked_members + if item.user_id == team.captain_id + and item.role == TeamMember.ROLE_CAPTAIN + and item.status == TeamMember.STATUS_ACCEPTED + ] + + if team.captain_id != application.user_id: + raise CaptainMismatchError() + if len(captain_members) != 1: + raise CaptainMemberMissingError() + if target is None: + raise TeamMemberNotFoundError() + if ( + target.role != TeamMember.ROLE_MEMBER + or target.status != TeamMember.STATUS_ACCEPTED + ): + raise CaptainTransferTargetInvalidError() + if not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=target.user, + ).exists(): + raise TeamMemberRegistrationMissingError( + "Новый капитан должен быть зарегистрирован на активность." + ) + + require_no_active_conflict( + program=program, + user=target.user, + exclude_application=application, + ) + + old_captain_member = captain_members[0] + old_captain_member.role = TeamMember.ROLE_MEMBER + old_captain_member.save(update_fields=["role", "updated_at"]) + + application.user = target.user + application.save(update_fields=["user", "updated_at"]) + + team.application = application + team.captain = target.user + team.save(update_fields=["captain", "updated_at"]) + + target.team = team + target.role = TeamMember.ROLE_CAPTAIN + target.save(update_fields=["role", "updated_at"]) + return team diff --git a/partner_programs/submission_views.py b/partner_programs/submission_views.py index 07728745..d49af696 100644 --- a/partner_programs/submission_views.py +++ b/partner_programs/submission_views.py @@ -1,16 +1,17 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError, transaction -from django.db.models import Max +from django.db.models import Max, Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from core.throttling import PostOnlyScopedRateThrottle -from partner_programs.models import Application, Submission +from partner_programs.models import Application, Submission, TeamMember +from partner_programs.permissions import can_edit_application, can_edit_submission from partner_programs.serializers import SubmissionSerializer @@ -18,7 +19,14 @@ def _application_queryset_for(user): queryset = Application.objects.select_related("program", "user", "created_by") if user.is_staff or user.is_superuser: return queryset - return queryset.filter(user=user) + return queryset.filter( + Q(user=user) + | Q( + team__members__user=user, + team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() def _submission_queryset_for(user): @@ -31,7 +39,14 @@ def _submission_queryset_for(user): ) if user.is_staff or user.is_superuser: return queryset - return queryset.filter(application__user=user) + return queryset.filter( + Q(application__user=user) + | Q( + application__team__members__user=user, + application__team__members__status=TeamMember.STATUS_ACCEPTED, + ) + | Q(program__managers=user) + ).distinct() def _submission_response(submission, response_status=status.HTTP_200_OK): @@ -63,6 +78,10 @@ def get(self, request, application_id): def post(self, request, application_id): application = self.get_application(request, application_id) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can create submissions." + ) if application.status not in ( Application.STATUS_SUBMITTED, Application.STATUS_APPROVED, @@ -88,9 +107,13 @@ def post(self, request, application_id): try: with transaction.atomic(): locked_application = get_object_or_404( - _application_queryset_for(request.user).select_for_update(), + Application.objects.select_for_update(), pk=application.pk, ) + if not can_edit_application(request.user, locked_application): + raise PermissionDenied( + "Only the application owner or staff can create submissions." + ) if locked_application.status not in ( Application.STATUS_SUBMITTED, Application.STATUS_APPROVED, @@ -153,23 +176,39 @@ def get(self, request, submission_id): return _submission_response(self.get_object(request, submission_id)) def patch(self, request, submission_id): - submission = self.get_object(request, submission_id) - if not submission.can_edit: - raise ValidationError( - {"status": "Only draft or returned submissions can be updated."} + visible_submission = self.get_object(request, submission_id) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can update submissions." + ) + with transaction.atomic(): + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can update submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application + if not submission.can_edit: + raise ValidationError( + {"status": "Only draft or returned submissions can be updated."} + ) - serializer = SubmissionSerializer( - submission, - data=request.data, - partial=True, - ) - serializer.is_valid(raise_exception=True) - try: - serializer.save() - except DjangoValidationError as exc: - raise ValidationError(exc.message_dict) from exc - return Response(serializer.data) + serializer = SubmissionSerializer( + submission, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + try: + serializer.save() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return Response(serializer.data) class SubmissionSubmitView(APIView): @@ -177,10 +216,25 @@ class SubmissionSubmitView(APIView): def post(self, request, submission_id): with transaction.atomic(): - submission = get_object_or_404( - _submission_queryset_for(request.user).select_for_update(), + visible_submission = get_object_or_404( + _submission_queryset_for(request.user), pk=submission_id, ) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can submit submissions." + ) + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can submit submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application if submission.status == Submission.STATUS_SUBMITTED: return _submission_response(submission) if not submission.can_submit: @@ -199,10 +253,25 @@ class SubmissionCancelView(APIView): def post(self, request, submission_id): with transaction.atomic(): - submission = get_object_or_404( - _submission_queryset_for(request.user).select_for_update(), + visible_submission = get_object_or_404( + _submission_queryset_for(request.user), pk=submission_id, ) + if not can_edit_submission(request.user, visible_submission): + raise PermissionDenied( + "Only the application owner or staff can cancel submissions." + ) + application = Application.objects.select_for_update().get( + pk=visible_submission.application_id + ) + if not can_edit_application(request.user, application): + raise PermissionDenied( + "Only the application owner or staff can cancel submissions." + ) + submission = Submission.objects.select_for_update().get( + pk=visible_submission.pk + ) + submission.application = application if submission.status == Submission.STATUS_CANCELLED: return _submission_response(submission) if not submission.can_edit: diff --git a/partner_programs/team_views.py b/partner_programs/team_views.py new file mode 100644 index 00000000..cb46c73e --- /dev/null +++ b/partner_programs/team_views.py @@ -0,0 +1,172 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from partner_programs.models import Team, TeamMember +from partner_programs.permissions import can_manage_team, can_view_team +from partner_programs.serializers import ( + TeamMemberSerializer, + TeamSerializer, + TeamTransferCaptainSerializer, + TeamUpdateSerializer, +) +from partner_programs.services.application_team import ApplicationTeamServiceError +from partner_programs.services.team_management import ( + TeamManagementPermissionError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) +from partner_programs.throttling import TeamMutationScopedRateThrottle + + +def _team_queryset(): + return Team.objects.select_related( + "application", + "application__program", + "application__user", + "captain", + ).prefetch_related("members__user") + + +def _get_team(application_id: int) -> Team: + return get_object_or_404(_team_queryset(), application_id=application_id) + + +def _team_response(team: Team, request) -> Response: + team = _team_queryset().get(pk=team.pk) + return Response(TeamSerializer(team, context={"request": request}).data) + + +def _raise_domain_error(exc: ApplicationTeamServiceError): + if isinstance(exc, TeamManagementPermissionError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + +class TeamDetailView(APIView): + """Возвращает Team и позволяет капитану/staff изменить только название.""" + permission_classes = [IsAuthenticated] + + def get(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + return _team_response(team, request) + + def patch(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + + serializer = TeamUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + team = rename_team( + team=team, + actor=request.user, + name=serializer.validated_data["name"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _team_response(team, request) + + def get_throttles(self): + if self.request.method == "PATCH": + self.throttle_scope = "team_rename" + return [TeamMutationScopedRateThrottle()] + return [] + + +class TeamLeaveView(APIView): + """Фиксирует самостоятельный выход обычного accepted-участника.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_leave" + + def post(self, request, application_id): + team = _get_team(application_id) + has_membership_history = TeamMember.objects.filter( + team=team, + user=request.user, + ).exists() + if not can_view_team(request.user, team) and not has_membership_history: + raise NotFound("Team not found.") + if not has_membership_history: + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + try: + member = leave_team(team=team, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamMemberSerializer(member).data, status=status.HTTP_200_OK) + + +class TeamMemberRemoveView(APIView): + """Фиксирует исключение участника капитаном или staff без удаления строки.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_remove_member" + + def post(self, request, application_id, member_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + try: + member = remove_team_member( + team=team, + actor=request.user, + member_id=member_id, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamMemberSerializer(member).data, status=status.HTTP_200_OK) + + +class TeamTransferCaptainView(APIView): + """Передает капитанство через атомарный domain service.""" + permission_classes = [IsAuthenticated] + throttle_classes = [TeamMutationScopedRateThrottle] + throttle_scope = "team_transfer_captain" + + def post(self, request, application_id): + team = _get_team(application_id) + if not can_view_team(request.user, team): + raise NotFound("Team not found.") + if not can_manage_team(request.user, team): + raise PermissionDenied( + TeamManagementPermissionError.default_detail, + code=TeamManagementPermissionError.code, + ) + serializer = TeamTransferCaptainSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + team = transfer_team_captain( + team=team, + actor=request.user, + member_id=serializer.validated_data["member_id"], + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return _team_response(team, request) diff --git a/partner_programs/tests/test_team_api.py b/partner_programs/tests/test_team_api.py new file mode 100644 index 00000000..ecb64494 --- /dev/null +++ b/partner_programs/tests/test_team_api.py @@ -0,0 +1,528 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgram, + Submission, + TeamMember, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import TeamMutationScopedRateThrottle + + +class TeamAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.captain = create_user(prefix="team-api-captain") + self.member_user = create_user(prefix="team-api-member") + self.manager = create_user(prefix="team-api-manager") + self.staff = create_user(prefix="team-api-staff", is_staff=True) + self.outsider = create_user(prefix="team-api-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="API команда", + ).application + self.team = self.application.team + self.member = TeamMember.objects.create( + team=self.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + @property + def team_url(self): + return f"/applications/{self.application.pk}/team/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def add_member(self, *, status=TeamMember.STATUS_ACCEPTED, register=True): + user = create_user(prefix="team-api-extra") + if register: + create_program_member(self.program, user=user) + return TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=self.captain, + ) + + def test_unauthenticated_user_cannot_read_team(self): + response = self.client.get(self.team_url) + self.assertEqual(response.status_code, 401) + + def test_captain_member_manager_and_staff_can_read_team(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + self.authenticate(user) + with self.subTest(user=user): + response = self.client.get(self.team_url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.team.pk) + + def test_outsider_and_historical_members_cannot_read_team(self): + self.authenticate(self.outsider) + self.assertEqual(self.client.get(self.team_url).status_code, 404) + + for membership_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = membership_status + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.member_user) + with self.subTest(status=membership_status): + self.assertEqual(self.client.get(self.team_url).status_code, 404) + + def test_individual_application_has_no_team_endpoint(self): + owner = create_user(prefix="individual-team-api") + individual_program = create_partner_program() + application = Application.objects.create( + program=individual_program, + user=owner, + created_by=owner, + ) + self.authenticate(owner) + + response = self.client.get(f"/applications/{application.pk}/team/") + + self.assertEqual(response.status_code, 404) + + def test_team_response_is_sorted_and_does_not_expose_private_user_data(self): + invited = self.add_member(status=TeamMember.STATUS_INVITED, register=False) + self.authenticate(self.captain) + + response = self.client.get(self.team_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["application_id"], self.application.pk) + self.assertEqual(response.data["program_id"], self.program.pk) + self.assertEqual(response.data["team_min_size"], 2) + self.assertEqual(response.data["team_max_size"], 5) + self.assertEqual(response.data["accepted_members_count"], 2) + self.assertEqual(response.data["current_user_role"], TeamMember.ROLE_CAPTAIN) + member_ids = [item["id"] for item in response.data["members"]] + self.assertEqual(member_ids[0], self.team.members.get(role="captain").pk) + self.assertEqual(member_ids[-1], invited.pk) + for item in response.data["members"]: + self.assertEqual( + set(item["user"]), + {"id", "display_name", "avatar"}, + ) + self.assertNotIn("email", item["user"]) + + def test_capability_flags_follow_role_and_draft_state(self): + expected = { + self.captain: (True, True, False), + self.member_user: (False, False, True), + self.manager: (False, False, False), + self.staff: (True, True, False), + } + for user, flags in expected.items(): + self.authenticate(user) + response = self.client.get(self.team_url) + with self.subTest(user=user): + self.assertEqual( + ( + response.data["can_edit"], + response.data["can_manage_members"], + response.data["can_leave"], + ), + flags, + ) + + def test_captain_can_rename_team_and_only_name_is_writable(self): + self.authenticate(self.captain) + response = self.client.patch( + self.team_url, + {"name": "Новое имя"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "Новое имя") + + blocked = self.client.patch( + self.team_url, + { + "name": "Попытка", + "captain": self.member_user.pk, + "application": 999, + "members": [], + }, + format="json", + ) + self.assertEqual(blocked.status_code, 400) + self.team.refresh_from_db() + self.assertEqual(self.team.name, "Новое имя") + self.assertEqual(self.team.captain, self.captain) + + def test_member_and_manager_cannot_rename_team(self): + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + response = self.client.patch( + self.team_url, + {"name": "Запрещено"}, + format="json", + ) + self.assertEqual(response.status_code, 403) + + def test_rename_is_blocked_after_deadline_or_submit(self): + self.authenticate(self.captain) + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=( + timezone.now() - timezone.timedelta(seconds=1) + ) + ) + self.assertEqual( + self.client.patch( + self.team_url, + {"name": "Поздно"}, + format="json", + ).status_code, + 400, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + self.assertEqual( + self.client.patch( + self.team_url, + {"name": "Отправлено"}, + format="json", + ).status_code, + 400, + ) + + @patch.object(TeamMutationScopedRateThrottle, "rate", "1/min") + def test_team_rename_has_scoped_throttle(self): + self.authenticate(self.captain) + first = self.client.patch(self.team_url, {"name": "Раз"}, format="json") + second = self.client.patch(self.team_url, {"name": "Два"}, format="json") + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 429) + + def test_member_can_leave_and_repeat_is_stable_error(self): + self.authenticate(self.member_user) + url = f"/applications/{self.application.pk}/team/leave/" + first = self.client.post(url, {}, format="json") + second = self.client.post(url, {}, format="json") + + self.assertEqual(first.status_code, 200) + self.assertEqual(first.data["status"], TeamMember.STATUS_LEFT) + self.assertEqual(second.status_code, 400) + self.member.refresh_from_db() + self.assertIsNotNone(self.member.joined_at) + + def test_captain_cannot_leave_and_member_cannot_leave_after_submit(self): + self.authenticate(self.captain) + url = f"/applications/{self.application.pk}/team/leave/" + self.assertEqual(self.client.post(url, {}, format="json").status_code, 400) + + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + self.authenticate(self.member_user) + self.assertEqual(self.client.post(url, {}, format="json").status_code, 400) + + def test_captain_can_remove_member_but_member_and_manager_cannot(self): + url = ( + f"/applications/{self.application.pk}/team/members/" + f"{self.member.pk}/remove/" + ) + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual( + self.client.post(url, {}, format="json").status_code, + 403, + ) + + self.authenticate(self.captain) + response = self.client.post(url, {}, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], TeamMember.STATUS_REMOVED) + + def test_remove_rejects_captain_or_member_of_another_team(self): + captain_member = self.team.members.get(role=TeamMember.ROLE_CAPTAIN) + self.authenticate(self.captain) + captain_url = ( + f"/applications/{self.application.pk}/team/members/" + f"{captain_member.pk}/remove/" + ) + self.assertEqual( + self.client.post(captain_url, {}, format="json").status_code, + 400, + ) + + other = self.add_member() + other.team = self.team + wrong_url = ( + f"/applications/{self.application.pk + 999}/team/members/" + f"{other.pk}/remove/" + ) + self.assertEqual( + self.client.post(wrong_url, {}, format="json").status_code, + 404, + ) + + def test_captain_can_transfer_captainship(self): + self.authenticate(self.captain) + response = self.client.post( + f"/applications/{self.application.pk}/team/transfer-captain/", + {"member_id": self.member.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.application.refresh_from_db() + self.team.refresh_from_db() + self.assertEqual(self.application.user, self.member_user) + self.assertEqual(self.team.captain, self.member_user) + + def test_transfer_rejects_manager_and_invalid_target(self): + url = f"/applications/{self.application.pk}/team/transfer-captain/" + self.authenticate(self.manager) + self.assertEqual( + self.client.post( + url, + {"member_id": self.member.pk}, + format="json", + ).status_code, + 403, + ) + + self.member.status = TeamMember.STATUS_INVITED + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + url, + {"member_id": self.member.pk}, + format="json", + ).status_code, + 400, + ) + + def test_application_response_contains_compact_team_summary(self): + self.authenticate(self.captain) + response = self.client.get(f"/applications/{self.application.pk}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.data["team"]), + { + "id", + "name", + "captain_id", + "accepted_members_count", + "current_user_role", + }, + ) + self.assertEqual(response.data["team"]["accepted_members_count"], 2) + self.assertIn("form_data", response.data) + self.assertIn("participation_mode", response.data) + + def test_member_and_manager_get_read_only_application_access(self): + detail_url = f"/applications/{self.application.pk}/" + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual(self.client.get(detail_url).status_code, 200) + self.assertEqual( + self.client.patch( + detail_url, + {"form_data": {"changed": True}}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/applications/{self.application.pk}/submit/", + {}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/applications/{self.application.pk}/withdraw/", + {}, + format="json", + ).status_code, + 403, + ) + + def test_my_application_prefers_owned_then_accepted_team_membership(self): + self.authenticate(self.member_user) + url = f"/programs/{self.program.pk}/applications/my/" + membership_response = self.client.get(url) + self.assertEqual(membership_response.status_code, 200) + self.assertEqual(membership_response.data["id"], self.application.pk) + + own = Application.objects.create( + program=self.program, + user=self.member_user, + created_by=self.member_user, + ) + own_response = self.client.get(url) + self.assertEqual(own_response.data["id"], own.pk) + + def test_my_application_returns_latest_terminal_team_membership(self): + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_WITHDRAWN, + withdrawn_at=timezone.now(), + ) + self.authenticate(self.member_user) + + response = self.client.get( + f"/programs/{self.program.pk}/applications/my/" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], self.application.pk) + + def test_historical_member_does_not_get_application_access(self): + self.member.status = TeamMember.STATUS_REMOVED + self.member.save(update_fields=["status", "updated_at"]) + self.authenticate(self.member_user) + + self.assertEqual( + self.client.get(f"/applications/{self.application.pk}/").status_code, + 404, + ) + self.assertEqual( + self.client.get( + f"/programs/{self.program.pk}/applications/my/" + ).status_code, + 404, + ) + + def test_member_and_manager_have_read_only_submission_access(self): + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Командное решение", + ) + list_url = f"/applications/{self.application.pk}/submissions/" + detail_url = f"/submissions/{submission.pk}/" + + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual(self.client.get(list_url).status_code, 200) + self.assertEqual(self.client.get(detail_url).status_code, 200) + self.assertEqual( + self.client.post( + list_url, + {"title": "Запрещено"}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.patch( + detail_url, + {"title": "Запрещено"}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/submissions/{submission.pk}/submit/", + {}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.post( + f"/submissions/{submission.pk}/cancel/", + {}, + format="json", + ).status_code, + 403, + ) + + def test_outsider_cannot_read_submission(self): + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Закрытое решение", + ) + self.authenticate(self.outsider) + self.assertEqual( + self.client.get(f"/submissions/{submission.pk}/").status_code, + 404, + ) + + def test_transfer_moves_submission_write_access_to_new_captain(self): + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Черновик", + ) + self.authenticate(self.captain) + transfer_response = self.client.post( + f"/applications/{self.application.pk}/team/transfer-captain/", + {"member_id": self.member.pk}, + format="json", + ) + self.assertEqual(transfer_response.status_code, 200) + + detail_url = f"/submissions/{submission.pk}/" + old_captain_response = self.client.patch( + detail_url, + {"title": "Запрещено"}, + format="json", + ) + self.assertEqual(old_captain_response.status_code, 403) + self.assertEqual(self.client.get(detail_url).status_code, 200) + + self.authenticate(self.member_user) + new_captain_response = self.client.patch( + detail_url, + {"title": "Новый капитан"}, + format="json", + ) + self.assertEqual(new_captain_response.status_code, 200) + self.assertEqual(new_captain_response.data["title"], "Новый капитан") diff --git a/partner_programs/tests/test_team_management_service.py b/partner_programs/tests/test_team_management_service.py new file mode 100644 index 00000000..5ced63e5 --- /dev/null +++ b/partner_programs/tests/test_team_management_service.py @@ -0,0 +1,415 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram, Team, TeamMember +from partner_programs.permissions import can_edit_application +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + TeamMemberRegistrationMissingError, + create_or_get_application, +) +from partner_programs.services.team_management import ( + CaptainTransferRequiredError, + CaptainTransferTargetInvalidError, + TeamManagementPermissionError, + TeamMemberNotFoundError, + TeamMemberNotRemovableError, + TeamMembershipNotActiveError, + leave_team, + remove_team_member, + rename_team, + transfer_team_captain, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) + + +class TeamManagementServiceTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="team-service-captain") + self.member_user = create_user(prefix="team-service-member") + self.manager = create_user(prefix="team-service-manager") + self.staff = create_user(prefix="team-service-staff", is_staff=True) + self.outsider = create_user(prefix="team-service-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name="Команда", + ).application + self.team = self.application.team + self.member = TeamMember.objects.create( + team=self.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + + def add_member(self, *, status=TeamMember.STATUS_ACCEPTED, register=True): + user = create_user(prefix="team-service-extra") + if register: + create_program_member(self.program, user=user) + return TeamMember.objects.create( + team=self.team, + user=user, + role=TeamMember.ROLE_MEMBER, + status=status, + invited_by=self.captain, + ) + + def close_deadline(self): + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=( + timezone.now() - timezone.timedelta(seconds=1) + ) + ) + + def test_captain_and_staff_can_rename_draft_team(self): + renamed = rename_team(team=self.team, actor=self.captain, name="Новое имя") + self.assertEqual(renamed.name, "Новое имя") + + renamed = rename_team(team=renamed, actor=self.staff, name="Имя staff") + self.assertEqual(renamed.name, "Имя staff") + + def test_member_manager_and_outsider_cannot_rename_team(self): + for actor in (self.member_user, self.manager, self.outsider): + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + rename_team(team=self.team, actor=actor, name="Запрещено") + + def test_rename_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + rename_team(team=self.team, actor=self.captain, name="Поздно") + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + rename_team(team=self.team, actor=self.captain, name="Отправлено") + + def test_accepted_member_can_leave_and_joined_at_is_preserved(self): + joined_at = self.member.joined_at + left = leave_team(team=self.team, actor=self.member_user) + + self.assertEqual(left.status, TeamMember.STATUS_LEFT) + self.assertEqual(left.joined_at, joined_at) + + def test_repeated_leave_returns_stable_domain_error(self): + leave_team(team=self.team, actor=self.member_user) + + with self.assertRaises(TeamMembershipNotActiveError) as context: + leave_team(team=self.team, actor=self.member_user) + + self.assertEqual(context.exception.code, "team_membership_not_active") + + def test_captain_must_transfer_before_leaving(self): + with self.assertRaises(CaptainTransferRequiredError) as context: + leave_team(team=self.team, actor=self.captain) + self.assertEqual(context.exception.code, "captain_transfer_required") + + def test_leave_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + leave_team(team=self.team, actor=self.member_user) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + leave_team(team=self.team, actor=self.member_user) + + def test_captain_removes_accepted_or_invited_member_without_deleting_history(self): + joined_at = self.member.joined_at + removed = remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + self.assertEqual(removed.status, TeamMember.STATUS_REMOVED) + self.assertEqual(removed.joined_at, joined_at) + + invited = self.add_member(status=TeamMember.STATUS_INVITED, register=False) + removed_invited = remove_team_member( + team=self.team, + actor=self.captain, + member_id=invited.pk, + ) + self.assertEqual(removed_invited.status, TeamMember.STATUS_REMOVED) + self.assertTrue(TeamMember.objects.filter(pk=invited.pk).exists()) + + def test_staff_can_remove_member_but_member_and_manager_cannot(self): + removed = remove_team_member( + team=self.team, + actor=self.staff, + member_id=self.member.pk, + ) + self.assertEqual(removed.status, TeamMember.STATUS_REMOVED) + + for actor in (self.member_user, self.manager): + other = self.add_member() + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + remove_team_member( + team=self.team, + actor=actor, + member_id=other.pk, + ) + + def test_captain_cannot_remove_self(self): + captain_member = self.team.members.get(role=TeamMember.ROLE_CAPTAIN) + with self.assertRaises(CaptainTransferRequiredError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=captain_member.pk, + ) + + def test_remove_rejects_member_of_other_team_and_historical_status(self): + other_captain = create_user(prefix="other-team-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + other_member = other_application.team.members.get() + + with self.assertRaises(TeamMemberNotFoundError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=other_member.pk, + ) + + self.member.status = TeamMember.STATUS_LEFT + self.member.save(update_fields=["status", "updated_at"]) + with self.assertRaises(TeamMemberNotRemovableError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_remove_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + remove_team_member( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_updates_owner_captain_roles_and_preserves_history(self): + project = create_project(leader=self.captain) + self.application.project = project + self.application.save(update_fields=["project", "updated_at"]) + created_by_id = self.application.created_by_id + + transferred = transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + self.application.refresh_from_db() + self.member.refresh_from_db() + old_member = transferred.members.get(user=self.captain) + self.assertEqual(transferred.captain, self.member_user) + self.assertEqual(self.application.user, self.member_user) + self.assertEqual(self.application.created_by_id, created_by_id) + self.assertEqual(self.application.project, project) + self.assertEqual(self.member.role, TeamMember.ROLE_CAPTAIN) + self.assertEqual(self.member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual(old_member.role, TeamMember.ROLE_MEMBER) + self.assertEqual(old_member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual( + transferred.members.filter( + role=TeamMember.ROLE_CAPTAIN, + status=TeamMember.STATUS_ACCEPTED, + ).count(), + 1, + ) + self.assertTrue(can_edit_application(self.member_user, self.application)) + self.assertFalse(can_edit_application(self.captain, self.application)) + + def test_staff_can_transfer_captain(self): + transferred = transfer_team_captain( + team=self.team, + actor=self.staff, + member_id=self.member.pk, + ) + self.assertEqual(transferred.captain, self.member_user) + + def test_transfer_rejects_non_manager_actor(self): + for actor in (self.member_user, self.manager, self.outsider): + with self.subTest(actor=actor): + with self.assertRaises(TeamManagementPermissionError): + transfer_team_captain( + team=self.team, + actor=actor, + member_id=self.member.pk, + ) + + def test_transfer_rejects_non_accepted_or_non_member_target(self): + for member_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = member_status + self.member.save(update_fields=["status", "updated_at"]) + with self.subTest(status=member_status): + with self.assertRaises(CaptainTransferTargetInvalidError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_member_of_other_team(self): + other_captain = create_user(prefix="transfer-other-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + target = other_application.team.members.get() + + with self.assertRaises(TeamMemberNotFoundError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=target.pk, + ) + + def test_transfer_requires_program_registration(self): + self.member_user.partner_program_profiles.filter( + partner_program=self.program + ).delete() + + with self.assertRaises(TeamMemberRegistrationMissingError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_target_with_owned_active_application(self): + Application.objects.create( + program=self.program, + user=self.member_user, + created_by=self.member_user, + ) + + with self.assertRaises(ActiveApplicationConflictError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rejects_target_in_another_active_team(self): + other_captain = create_user(prefix="conflict-team-captain") + create_program_member(self.program, user=other_captain) + other_application = create_or_get_application( + program=self.program, + user=other_captain, + created_by=other_captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + TeamMember.objects.create( + team=other_application.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + with self.assertRaises(ActiveApplicationConflictError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_is_blocked_after_deadline_or_submit(self): + self.close_deadline() + with self.assertRaises(ApplicationDeadlinePassedError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + PartnerProgram.objects.filter(pk=self.program.pk).update( + datetime_application_ends=None + ) + Application.objects.filter(pk=self.application.pk).update( + status=Application.STATUS_SUBMITTED + ) + with self.assertRaises(ApplicationNotEditableError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + def test_transfer_rolls_back_if_update_fails_midway(self): + with patch.object(Team, "save", side_effect=RuntimeError("forced error")): + with self.assertRaises(RuntimeError): + transfer_team_captain( + team=self.team, + actor=self.captain, + member_id=self.member.pk, + ) + + self.application.refresh_from_db() + self.team.refresh_from_db() + self.member.refresh_from_db() + old_captain = self.team.members.get(user=self.captain) + self.assertEqual(self.application.user, self.captain) + self.assertEqual(self.team.captain, self.captain) + self.assertEqual(old_captain.role, TeamMember.ROLE_CAPTAIN) + self.assertEqual(self.member.role, TeamMember.ROLE_MEMBER) diff --git a/partner_programs/tests/test_team_permissions.py b/partner_programs/tests/test_team_permissions.py new file mode 100644 index 00000000..43c393c5 --- /dev/null +++ b/partner_programs/tests/test_team_permissions.py @@ -0,0 +1,115 @@ +from django.test import TestCase + +from partner_programs.models import Application, PartnerProgram, Submission, TeamMember +from partner_programs.permissions import ( + can_edit_application, + can_edit_submission, + can_manage_team, + can_view_application, + can_view_submission, + can_view_team, + is_accepted_team_member, + is_application_owner, + is_program_manager, + is_team_captain, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class TeamPermissionHelperTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="team-permission-captain") + self.member_user = create_user(prefix="team-permission-member") + self.manager = create_user(prefix="team-permission-manager") + self.staff = create_user(prefix="team-permission-staff", is_staff=True) + self.outsider = create_user(prefix="team-permission-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + self.program.managers.add(self.manager) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.member_user) + self.application = create_or_get_application( + program=self.program, + user=self.captain, + created_by=self.captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + ).application + self.member = TeamMember.objects.create( + team=self.application.team, + user=self.member_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + self.submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Решение", + ) + + def test_role_helpers_distinguish_owner_captain_member_and_manager(self): + self.assertTrue(is_application_owner(self.captain, self.application)) + self.assertTrue(is_team_captain(self.captain, self.application.team)) + self.assertTrue( + is_accepted_team_member(self.member_user, self.application.team) + ) + self.assertTrue(is_program_manager(self.manager, self.program)) + self.assertFalse(is_application_owner(self.member_user, self.application)) + self.assertFalse(is_team_captain(self.member_user, self.application.team)) + + def test_application_read_access_matches_role_boundary(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_application(user, self.application)) + self.assertFalse(can_view_application(self.outsider, self.application)) + + def test_application_write_access_is_owner_or_staff_only(self): + self.assertTrue(can_edit_application(self.captain, self.application)) + self.assertTrue(can_edit_application(self.staff, self.application)) + self.assertFalse(can_edit_application(self.member_user, self.application)) + self.assertFalse(can_edit_application(self.manager, self.application)) + + def test_team_access_separates_view_and_manage(self): + team = self.application.team + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_team(user, team)) + self.assertTrue(can_manage_team(self.captain, team)) + self.assertTrue(can_manage_team(self.staff, team)) + self.assertFalse(can_manage_team(self.member_user, team)) + self.assertFalse(can_manage_team(self.manager, team)) + + def test_historical_membership_does_not_grant_read_access(self): + for membership_status in ( + TeamMember.STATUS_INVITED, + TeamMember.STATUS_DECLINED, + TeamMember.STATUS_REMOVED, + TeamMember.STATUS_LEFT, + ): + self.member.status = membership_status + self.member.save(update_fields=["status", "updated_at"]) + with self.subTest(status=membership_status): + self.assertFalse( + is_accepted_team_member(self.member_user, self.application.team) + ) + self.assertFalse(can_view_team(self.member_user, self.application.team)) + self.assertFalse(can_view_application(self.member_user, self.application)) + + def test_submission_read_is_inherited_but_write_is_not(self): + for user in (self.captain, self.member_user, self.manager, self.staff): + with self.subTest(user=user): + self.assertTrue(can_view_submission(user, self.submission)) + self.assertFalse(can_view_submission(self.outsider, self.submission)) + self.assertTrue(can_edit_submission(self.captain, self.submission)) + self.assertTrue(can_edit_submission(self.staff, self.submission)) + self.assertFalse(can_edit_submission(self.member_user, self.submission)) + self.assertFalse(can_edit_submission(self.manager, self.submission)) diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py new file mode 100644 index 00000000..3b9c6164 --- /dev/null +++ b/partner_programs/throttling.py @@ -0,0 +1,10 @@ +from rest_framework.throttling import ScopedRateThrottle + + +class TeamMutationScopedRateThrottle(ScopedRateThrottle): + """Ограничивает только mutation Team, не меняя глобальную throttle policy.""" + + rate = "20/min" + + def get_rate(self): + return self.rate