From 36974e8d59fca9c860c0475062fe39b70de87710 Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 22 Jul 2026 15:44:52 +0300 Subject: [PATCH] Add team invitation flow --- docs/application-team-model.md | 13 +- docs/application-team-service.md | 15 +- docs/case-championship-domain.md | 42 +- docs/program-participation-policy.md | 2 +- docs/team-invites-api.md | 108 +++++ docs/team-permissions-api.md | 16 +- partner_programs/admin.py | 50 +++ partner_programs/application_urls.py | 6 + ...eaminvite_uniq_pending_invite_team_user.py | 38 ++ partner_programs/models.py | 77 ++++ partner_programs/serializers/__init__.py | 8 + partner_programs/serializers/team_invites.py | 102 +++++ partner_programs/services/__init__.py | 28 ++ partner_programs/services/application_team.py | 5 + partner_programs/services/team_invites.py | 333 ++++++++++++++ partner_programs/team_invite_urls.py | 21 + partner_programs/team_invite_views.py | 203 +++++++++ .../tests/test_team_invite_api.py | 417 ++++++++++++++++++ .../tests/test_team_invite_model.py | 84 ++++ .../tests/test_team_invite_service.py | 330 ++++++++++++++ partner_programs/throttling.py | 9 + procollab/urls.py | 4 + 22 files changed, 1874 insertions(+), 37 deletions(-) create mode 100644 docs/team-invites-api.md create mode 100644 partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py create mode 100644 partner_programs/serializers/team_invites.py create mode 100644 partner_programs/services/team_invites.py create mode 100644 partner_programs/team_invite_urls.py create mode 100644 partner_programs/team_invite_views.py create mode 100644 partner_programs/tests/test_team_invite_api.py create mode 100644 partner_programs/tests/test_team_invite_model.py create mode 100644 partner_programs/tests/test_team_invite_service.py diff --git a/docs/application-team-model.md b/docs/application-team-model.md index bec4fb19..44f48bd8 100644 --- a/docs/application-team-model.md +++ b/docs/application-team-model.md @@ -10,8 +10,8 @@ Транзакционный Application/Team service создает командный draft вместе с Team и accepted-капитаном и проверяет полный invariant перед submit. Публичный Team API -дает ролевой read-доступ, rename/leave/remove/transfer, но намеренно не добавляет -нового участника без будущего TeamInvite. +дает ролевой read-доступ, rename/leave/remove/transfer. Отдельный TeamInvite API +добавляет нового участника только после его явного accept. ## Participation mode @@ -63,8 +63,9 @@ policy, а редактируемость Team выводится из `Applicat Для первоначального captain member `invited_by` остается `null`: капитан не принимает собственное приглашение, а создается как владелец команды. Статус -`invited` для обычных участников пока является только модельной заготовкой; -механизм TeamInvite в этом PR отсутствует. +`invited` в TeamMember остается исторической модельной заготовкой и не является +источником истины приглашения. Pending/accepted/declined/revoked lifecycle +хранится отдельно в `TeamInvite`; только accept создает accepted TeamMember. При первом сохранении статуса `accepted` модель автоматически заполняет `joined_at`, если дата не передана. При переходе в `removed` или `left` дата не @@ -127,8 +128,8 @@ Domain service выполняет последовательность атом Следующие PR должны добавить: -- TeamInvite, accept/decline/revoke/expire; -- email и внутренние уведомления; +- email/token/link приглашения и expiry; +- email и внутренние уведомления для TeamInvite; - frontend wizard и вкладку команды. Legacy Application withdraw, Project/`projects.Collaborator`, diff --git a/docs/application-team-service.md b/docs/application-team-service.md index 561ef689..67b9eaab 100644 --- a/docs/application-team-service.md +++ b/docs/application-team-service.md @@ -87,7 +87,7 @@ Application. Cross-table invariant нельзя выразить обычным UniqueConstraint. Team transfer использует ту же проверку под общей блокировкой Program; прямые записи через -admin/model и будущий accept TeamInvite также обязаны проходить domain service. +admin/model и TeamInvite accept также обязаны проходить domain service. ## Полный Team invariant @@ -127,8 +127,9 @@ Application routes сохраняют прежние поля и действи - `PATCH /applications//` проводит mode/team name через service; - `POST /applications//submit/` вызывает транзакционный submit service. -Публичный Team API отдельно описан в `docs/team-permissions-api.md`. Прямого -добавления участника и TeamInvite по-прежнему нет. +Публичный Team API отдельно описан в `docs/team-permissions-api.md`, а lifecycle +приглашений — в `docs/team-invites-api.md`. Прямого добавления accepted-member +по `user_id` нет: членство появляется только через accept TeamInvite. ## Ограничения concurrency и MVP @@ -136,7 +137,7 @@ Production-база с row-level locking сериализует service-опер SQLite не реализует полноценный `select_for_update`, поэтому автоматический тест проверяет устойчивую последовательную конфликтную операцию без threading. -Service пока не управляет приглашениями/принятием участников и не блокирует -прямое редактирование моделей через admin. Team permissions, read access и -передача капитанства реализованы отдельным service/API; returned Application и -organizer review отсутствуют. +Отдельный TeamInvite service управляет приглашениями и принятием участников, +используя ту же Program-lock и проверку cross-table конфликтов. Прямое +редактирование моделей через admin по-прежнему может обходить services; +returned Application и organizer review отсутствуют. diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index 716e304b..d405d743 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -138,7 +138,7 @@ API дает ролевое чтение, rename/leave/remove/transfer без п **Текущее состояние.** Реализована как `partner_programs.TeamMember` с ролями, пятью статусами, `invited_by` и `joined_at`. `projects.Collaborator` продолжает хранить только участников проекта. Cross-Application конфликт проверяется при -create/submit/transfer; публичного механизма приглашений еще нет. +create/submit/transfer и accept TeamInvite. ### TeamInvite @@ -154,9 +154,11 @@ nullable, `invited_by` и timestamps. **Что не должна хранить.** Членство как факт: только принятое приглашение делает пользователя участником команды. -**Текущее состояние.** Отсутствует. Имеющаяся `invites.Invite` предназначена -исключительно для приглашения в `Project`, хранит tri-state -`is_accepted` и не имеет token, email, expiry или связи с Application Team. +**Текущее состояние.** Реализован platform-user MVP: `TeamInvite` связан с +Team, обязательными `user`/`invited_by`, имеет статусы pending/accepted/ +declined/revoked, историю, admin и create/my/accept/decline/revoke API. Email, +token, link, expiry и notifications отсутствуют. Имеющаяся `invites.Invite` +по-прежнему предназначена исключительно для приглашения в `Project`. ### Project @@ -629,17 +631,17 @@ Registration. | 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, 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` | +| Team | One-to-one Application, name, captain, timestamps, validation/admin, creation/invariant/management services и public API | Add member только через TeamInvite accept | Email/link invite integration | Mutation только draft до application deadline | +| TeamMember | Roles/statuses, invited_by, joined_at, constraints, validation, admin, read/leave/remove/transfer | invited — только историческая заготовка | Organizer actions | Не переиспользует Project Collaborator | +| TeamInvite | Platform-user model/admin, history, create/my/accept/decline/revoke API, conflicts/capacity/locking/throttle | Только существующий `user_id` | Token/email/link/expiry/notifications | Pending не дает membership или read-доступ | | 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/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/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 | +| Admin | PartnerProgram, Registration, Application, Team, TeamMember, TeamInvite, Submission, Project, Invite и legacy evaluation зарегистрированы | Admin позволяет ручную диагностику | Evaluation admin | Admin не заменяет transition services | +| Tests | Model/service/permission/API tests Application/Team/TeamMember/TeamInvite/Submission и regression legacy flow | Нет notification flow | Evaluation/result tests нового flow | TeamInvite accept проверяется вместе с rollback и access boundary | ### Актуальные domain endpoints @@ -657,6 +659,11 @@ Registration. | 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 | +| TeamInvite | `GET/POST /applications//team/invites/` | История и создание platform-user приглашения капитаном/staff | +| TeamInvite | `GET /team-invites/my/` | Приглашения текущего пользователя, pending первыми | +| TeamInvite | `POST /team-invites//accept/` | Принять приглашение и создать/восстановить accepted TeamMember | +| TeamInvite | `POST /team-invites//decline/` | Отклонить свое pending-приглашение | +| TeamInvite | `POST /team-invites//revoke/` | Отозвать pending-приглашение капитаном/staff | | Submission | `GET/POST /applications//submissions/` | Список версий или новый draft Submission | | Submission | `GET/PATCH /submissions//` | Прочитать или изменить draft/returned | | Submission | `POST /submissions//submit/` | Отправить Submission | @@ -675,6 +682,8 @@ Registration. - `ApplicationAdmin` показывает Program, owner/creator, status, Project и даты; - `TeamAdmin` и `TeamMemberAdmin` показывают Program, капитана, роли, статусы и даты командного слоя; +- `TeamInviteAdmin` показывает Team, Program, адресата, отправителя, статус и + даты приглашения; - `SubmissionAdmin` показывает Application, Program, submitter, status, `stage_key`, version и даты; - legacy admin продолжает обслуживать Registration, PartnerProgramProject, @@ -691,14 +700,14 @@ Registration. | Требование | Текущее состояние | Требуемое изменение | Приоритет | Рекомендуемый PR | |---|---|---|---|---| | Явный формат участия | Поле/API и безопасный default `individual` реализованы | Подключить UI и позднее default `undecided` | P0 | Application participation wizard | -| Team и TeamMember | Модели, admin, constraints, services, permissions и public API реализованы | Добавить TeamInvite | P1 | TeamInvite model/API | +| Team и TeamMember | Модели, admin, constraints, services, permissions и public API реализованы | Добавить organizer policy | P1 | Application review 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 | 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 | +| Captain-only actions | Team-aware permissions, transfer и platform-user invites реализованы | Подключить review actions к тем же helpers | P1 | Application review API | +| Team API | Read/rename/leave/remove/transfer и invite lifecycle реализованы | Добавить notification delivery | P2 | Invite notifications | | Полный 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 | +| TeamInvite | Platform-user lifecycle и идемпотентный accept реализованы | Добавить token/email/link/expiry без смешения с Project Invite | P2 | Extended TeamInvite delivery | | 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 | @@ -726,9 +735,10 @@ Registration. 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, - accept/decline/revoke/expire и защиту от конфликтов участия. -7. **Invite notifications.** Отдельно подключить email и persistent in-app +6. **`feature/team-invites-api` — реализовано.** Добавлены platform-user + TeamInvite, accept/decline/revoke, блокировки, конфликты и capacity policy. +7. **Invite notifications.** Отдельно добавить email/token/link/expiry и + подключить email и persistent in-app notifications с retry/idempotency. 8. **Frontend participation wizard.** Выбор individual/team/later и создание draft только по явному сохранению. diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md index 90ca22b1..9873e8e0 100644 --- a/docs/program-participation-policy.md +++ b/docs/program-participation-policy.md @@ -83,5 +83,5 @@ Application create, смена participation mode и submit теперь при registration/project flow не изменен. Публичный Team API дает ролевое чтение и операции rename/leave/remove/transfer. -Будущий TeamInvite/accept service должен использовать те же cross-table +TeamInvite/accept service использует те же cross-table проверки без изменения legacy-дедлайнов. diff --git a/docs/team-invites-api.md b/docs/team-invites-api.md new file mode 100644 index 00000000..a64808f1 --- /dev/null +++ b/docs/team-invites-api.md @@ -0,0 +1,108 @@ +# Team Invites API + +## Назначение + +`TeamInvite` оформляет согласие существующего пользователя платформы войти в +команду конкретной `Application`. Приглашение не является членством и само по +себе не дает доступа к Application, Team или Submission. Только атомарный +`accept` создает либо восстанавливает `TeamMember` со статусом `accepted`. + +Новый flow не использует `invites.Invite`: legacy-модель по-прежнему относится +только к `Project` и не менялась. + +## Модель + +`TeamInvite` хранит: + +- `team`, `user` и `invited_by`; +- статус `pending`, `accepted`, `declined` или `revoked`; +- `resolved_at`, `created_at`, `updated_at`. + +Условный `UniqueConstraint(team, user)` действует только для `pending`. +Завершенные строки остаются историей, после них пользователя можно пригласить +повторно. Индексы поддерживают список приглашений команды и пользователя. +Создание `TeamInvite` намеренно не создает предварительный `TeamMember`. + +Миграция: `0022_teaminvite_teaminvite_uniq_pending_invite_team_user`. + +## Endpoints + +| Method | Endpoint | Доступ | Назначение | +|---|---|---|---| +| GET | `/applications//team/invites/` | captain, staff | История приглашений Team | +| POST | `/applications//team/invites/` | captain, staff | Создать pending по `{"user_id": id}` | +| GET | `/team-invites/my/` | invitee | Собственные приглашения, pending первыми | +| POST | `/team-invites//accept/` | invitee | Принять приглашение | +| POST | `/team-invites//decline/` | invitee | Отклонить приглашение | +| POST | `/team-invites//revoke/` | captain, staff | Отозвать приглашение | + +Manager и accepted member видят Team read-only, но не список приглашений и не +могут управлять им. Вложенные профили содержат только `id`, `display_name` и +`avatar`; email и закрытые поля не выдаются. + +Скрытый объект возвращает 404, известный объект без права на действие — 403, +нарушение статуса или domain invariant — 400. + +## Создание + +Создание разрешено только для командной draft-Application до +`datetime_application_ends`. Target должен: + +- существовать и не быть капитаном либо accepted member этой Team; +- иметь `PartnerProgramUserProfile` в Program; +- не владеть другой активной Application этой Program; +- не быть accepted member другой активной Team этой Program. + +Accepted-состав вместе с pending-приглашениями не должен превышать +`team_max_size`. Повторный запрос для уже существующего pending идемпотентно +возвращает его, не создавая новую строку. + +## Accept, decline и revoke + +Все переходы блокируют строки в порядке Program → Application → Team → +TeamInvite. `accept` повторно проверяет Registration, конфликт активного +участия, вместимость, draft и deadline, затем: + +1. создает или восстанавливает единственный `TeamMember(team, user)`; +2. устанавливает роль `member` и статус `accepted`; +3. сохраняет прежний `joined_at`, если пользователь уже состоял в Team; +4. переводит приглашение в `accepted` и заполняет `resolved_at`; +5. отзывает другие pending-приглашения пользователя в этой Program. + +`accept` уже принятого, `decline` уже отклоненного и `revoke` уже отозванного +приглашения идемпотентны. Другие терминальные переходы возвращают 400. После +submit Application новые create/accept/decline/revoke запрещены. + +Pending-приглашение блокирует смену team → individual, но не считается членом +команды при submit. Если accepted-состав соответствует policy Program, наличие +pending не мешает отправить Application. + +## Domain errors + +- `team_invite_permission_denied`; +- `team_invite_not_pending`; +- `team_invite_target_invalid`; +- `team_invite_registration_missing`; +- `team_invite_duplicate`; +- `team_invite_capacity_reached`; +- `team_invite_active_application_conflict`; +- `team_invite_not_owned`; +- общие `application_not_editable` и `application_deadline_passed`. + +Mutation endpoints имеют независимые локальные scopes +`team_invite_create/accept/decline/revoke` с rate `20/min`. GET endpoints не +ограничиваются этим throttle; глобальные DRF settings не менялись. + +## Ограничения MVP + +Приглашение работает только для существующего `user_id`. В MVP отсутствуют: + +- приглашение по email, token или ссылке; +- expiry; +- email и in-app notifications; +- поиск пользователей; +- organizer/manager mutation; +- frontend flow. + +`Team.captain`, `Application.user/created_by/project`, Project и +`projects.Collaborator` при принятии приглашения не меняются. diff --git a/docs/team-permissions-api.md b/docs/team-permissions-api.md index d19ae5d3..026c229a 100644 --- a/docs/team-permissions-api.md +++ b/docs/team-permissions-api.md @@ -7,9 +7,10 @@ `Project`; `projects.Collaborator` и legacy `invites.Invite` не участвуют в этом flow. -Новых участников нельзя добавить напрямую по `user_id`. Согласие пользователя -будет оформляться отдельной сущностью `TeamInvite`; этот API только читает -состав, меняет название, фиксирует выход/удаление и передает капитанство. +Новых участников нельзя добавить напрямую в TeamMember. Согласие пользователя +оформляется отдельной сущностью `TeamInvite`; ее endpoints описаны в +`docs/team-invites-api.md`. Этот API читает состав, меняет название, фиксирует +выход/удаление и передает капитанство. ## Роли и права @@ -117,8 +118,9 @@ Rate равен `20/min` на scope и пользователя/IP. Throttle л ## Ограничения MVP -В API намеренно отсутствуют прямой add member, TeamInvite, token/link invites, -email, уведомления и поиск пользователей. Manager получает только read-access; +В API намеренно отсутствуют прямой add member, token/link/email invites, +expiry, уведомления и поиск пользователей. Базовый TeamInvite по существующему +`user_id` реализован отдельно. Manager получает только read-access; organizer review, approve/reject/return, изменение Submission lifecycle и -expert permissions остаются отдельными PR. Frontend и legacy Project flow этим -изменением не затрагиваются. +expert permissions остаются отдельными PR. Frontend и legacy Project flow не +затрагиваются. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 352c9213..22985d8f 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -20,6 +20,7 @@ PartnerProgramUserProfile, Submission, Team, + TeamInvite, TeamMember, ) from partner_programs.services import prepare_project_scores_export_data @@ -159,6 +160,55 @@ def get_program(self, obj): return obj.team.application.program +@admin.register(TeamInvite) +class TeamInviteAdmin(admin.ModelAdmin): + list_display = ( + "id", + "team", + "get_program", + "user", + "invited_by", + "status", + "resolved_at", + "created_at", + ) + list_filter = ( + "status", + "team__application__program", + "created_at", + ) + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "invited_by__email", + "team__name", + "team__application__program__name", + "team__application__program__tag", + ) + raw_id_fields = ( + "team", + "user", + "invited_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + list_select_related = ( + "team", + "team__application", + "team__application__program", + "user", + "invited_by", + ) + date_hierarchy = "created_at" + + @admin.display(description="Программа", ordering="team__application__program") + def get_program(self, obj): + return obj.team.application.program + + @admin.register(Submission) class SubmissionAdmin(admin.ModelAdmin): list_display = ( diff --git a/partner_programs/application_urls.py b/partner_programs/application_urls.py index 52629d80..cd496fc5 100644 --- a/partner_programs/application_urls.py +++ b/partner_programs/application_urls.py @@ -6,6 +6,7 @@ ApplicationWithdrawView, ) from partner_programs.submission_views import ApplicationSubmissionListCreateView +from partner_programs.team_invite_views import TeamInviteListCreateView from partner_programs.team_views import ( TeamDetailView, TeamLeaveView, @@ -16,6 +17,11 @@ app_name = "applications" urlpatterns = [ + path( + "/team/invites/", + TeamInviteListCreateView.as_view(), + name="team-invite-list-create", + ), path( "/team/members//remove/", TeamMemberRemoveView.as_view(), diff --git a/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py b/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py new file mode 100644 index 00000000..4bb2d029 --- /dev/null +++ b/partner_programs/migrations/0022_teaminvite_teaminvite_uniq_pending_invite_team_user.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.11 on 2026-07-22 12:29 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('partner_programs', '0021_partnerprogram_datetime_application_ends_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='TeamInvite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Ожидает ответа'), ('accepted', 'Принято'), ('declined', 'Отклонено'), ('revoked', 'Отозвано')], default='pending', max_length=16, verbose_name='Статус')), + ('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата ответа')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sent_application_team_invites', to=settings.AUTH_USER_MODEL, verbose_name='Кем приглашен')), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='partner_programs.team', verbose_name='Команда')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='application_team_invites', to=settings.AUTH_USER_MODEL, verbose_name='Приглашенный пользователь')), + ], + options={ + 'verbose_name': 'Приглашение в команду заявки', + 'verbose_name_plural': 'Приглашения в команды заявок', + 'indexes': [models.Index(fields=['user', 'status', 'created_at'], name='team_invite_user_status_idx'), models.Index(fields=['team', 'status', 'created_at'], name='team_invite_team_status_idx')], + }, + ), + migrations.AddConstraint( + model_name='teaminvite', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('team', 'user'), name='uniq_pending_invite_team_user'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index f470c46d..8cc1c96f 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -623,6 +623,83 @@ def __str__(self): ) +class TeamInvite(models.Model): + """Приглашение зарегистрированного пользователя в команду заявки.""" + + STATUS_PENDING = "pending" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REVOKED = "revoked" + + STATUS_CHOICES = ( + (STATUS_PENDING, "Ожидает ответа"), + (STATUS_ACCEPTED, "Принято"), + (STATUS_DECLINED, "Отклонено"), + (STATUS_REVOKED, "Отозвано"), + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="invites", + verbose_name="Команда", + ) + user = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="application_team_invites", + verbose_name="Приглашенный пользователь", + ) + invited_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="sent_application_team_invites", + verbose_name="Кем приглашен", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_PENDING, + verbose_name="Статус", + ) + resolved_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата ответа", + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создано") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлено") + + class Meta: + verbose_name = "Приглашение в команду заявки" + verbose_name_plural = "Приглашения в команды заявок" + constraints = [ + # Завершенные приглашения сохраняются в истории и не мешают + # повторно пригласить пользователя в ту же команду. + models.UniqueConstraint( + fields=["team", "user"], + condition=models.Q(status="pending"), + name="uniq_pending_invite_team_user", + ), + ] + indexes = [ + models.Index( + fields=["user", "status", "created_at"], + name="team_invite_user_status_idx", + ), + models.Index( + fields=["team", "status", "created_at"], + name="team_invite_team_status_idx", + ), + ] + + def __str__(self): + return ( + f"TeamInvite<{self.pk}> team={self.team_id} " + f"user={self.user_id} status={self.status}" + ) + + class Submission(models.Model): """Versioned solution submitted for an application and program stage.""" diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 9833df50..596e1bab 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -17,6 +17,11 @@ UserProgramsSerializer, ) from .submissions import SubmissionSerializer +from .team_invites import ( + MyTeamInviteSerializer, + TeamInviteCreateSerializer, + TeamInviteSerializer, +) from .teams import ( ApplicationTeamSummarySerializer, TeamMemberSerializer, @@ -44,6 +49,9 @@ "ProgramProjectCreateSerializer", "ProgramProjectFilterRequestSerializer", "SubmissionSerializer", + "MyTeamInviteSerializer", + "TeamInviteCreateSerializer", + "TeamInviteSerializer", "TeamMemberSerializer", "TeamSerializer", "TeamTransferCaptainSerializer", diff --git a/partner_programs/serializers/team_invites.py b/partner_programs/serializers/team_invites.py new file mode 100644 index 00000000..b6f2ae7b --- /dev/null +++ b/partner_programs/serializers/team_invites.py @@ -0,0 +1,102 @@ +from rest_framework import serializers + +from partner_programs.models import Application, Team, TeamInvite +from partner_programs.serializers.teams import TeamUserSerializer + + +class TeamInviteCreateSerializer(serializers.Serializer): + """Принимает только публичный идентификатор зарегистрированного пользователя.""" + + user_id = serializers.IntegerField(min_value=1) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference({"user_id"}) + if unsupported: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(unsupported) + } + ) + return attrs + + +class TeamInviteSerializer(serializers.ModelSerializer): + """Безопасное представление приглашения для капитана и staff.""" + + user = TeamUserSerializer(read_only=True) + invited_by = TeamUserSerializer(read_only=True) + + class Meta: + model = TeamInvite + fields = ( + "id", + "user", + "invited_by", + "status", + "resolved_at", + "created_at", + "updated_at", + ) + read_only_fields = fields + + +class TeamInviteTeamSerializer(serializers.ModelSerializer): + class Meta: + model = Team + fields = ("id", "name") + read_only_fields = fields + + +class TeamInviteProgramSerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + + +def _invite_is_actionable(invite: TeamInvite) -> bool: + """Учитывает status приглашения, draft заявки и отдельный дедлайн.""" + application = invite.team.application + return ( + invite.status == TeamInvite.STATUS_PENDING + and application.status == Application.STATUS_DRAFT + and not application.program.is_application_deadline_passed() + ) + + +class MyTeamInviteSerializer(serializers.ModelSerializer): + """Показывает адресату контекст приглашения без приватных полей профиля.""" + + team = TeamInviteTeamSerializer(read_only=True) + application_id = serializers.IntegerField( + source="team.application_id", + read_only=True, + ) + captain = TeamUserSerializer(source="team.captain", read_only=True) + program = TeamInviteProgramSerializer( + source="team.application.program", + read_only=True, + ) + can_accept = serializers.SerializerMethodField() + can_decline = serializers.SerializerMethodField() + + class Meta: + model = TeamInvite + fields = ( + "id", + "status", + "team", + "application_id", + "captain", + "program", + "created_at", + "resolved_at", + "can_accept", + "can_decline", + ) + read_only_fields = fields + + def get_can_accept(self, invite: TeamInvite) -> bool: + return _invite_is_actionable(invite) + + def get_can_decline(self, invite: TeamInvite) -> bool: + return _invite_is_actionable(invite) diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py index b2d24477..c03d968f 100644 --- a/partner_programs/services/__init__.py +++ b/partner_programs/services/__init__.py @@ -32,6 +32,21 @@ rename_team, transfer_team_captain, ) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInviteCreationResult, + TeamInviteDuplicateError, + TeamInviteNotOwnedError, + TeamInviteNotPendingError, + TeamInvitePermissionError, + TeamInviteRegistrationMissingError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + revoke_team_invite, +) from partner_programs.services.exports import ( BASE_COLUMNS, ProgramExportFile, @@ -82,6 +97,15 @@ "ProjectScoreDataPreparer", "RegistrationRequiredError", "TeamHasOtherMembersError", + "TeamInviteActiveApplicationConflictError", + "TeamInviteCapacityReachedError", + "TeamInviteCreationResult", + "TeamInviteDuplicateError", + "TeamInviteNotOwnedError", + "TeamInviteNotPendingError", + "TeamInvitePermissionError", + "TeamInviteRegistrationMissingError", + "TeamInviteTargetInvalidError", "TeamManagementPermissionError", "TeamMemberNotFoundError", "TeamMemberNotRemovableError", @@ -91,18 +115,22 @@ "TeamRequiredError", "TeamSizeInvalidError", "apply_project_to_program", + "accept_team_invite", "build_program_field_columns", "build_program_project_scores_export_file", "build_program_projects_export_file", "change_application_participation_mode", "create_or_get_application", + "create_team_invite", "create_user_and_register_to_program", "get_filterable_program_fields", "get_filtered_program_project_links", + "decline_team_invite", "prepare_project_scores_export_data", "publish_finished_program_projects", "register_user_to_program", "remove_team_member", + "revoke_team_invite", "rename_team", "require_no_active_conflict", "require_can_apply_project_to_program", diff --git a/partner_programs/services/application_team.py b/partner_programs/services/application_team.py index c00e25f9..a97cc708 100644 --- a/partner_programs/services/application_team.py +++ b/partner_programs/services/application_team.py @@ -9,6 +9,7 @@ PartnerProgram, PartnerProgramUserProfile, Team, + TeamInvite, TeamMember, ) from projects.models import Project @@ -428,6 +429,10 @@ def change_application_participation_mode( user=team.captain, ).exists(): raise TeamHasOtherMembersError() + if team.invites.filter(status=TeamInvite.STATUS_PENDING).exists(): + raise TeamHasOtherMembersError( + "Нельзя сменить формат, пока у команды есть активные приглашения." + ) team.delete() team = None elif team is not None: diff --git a/partner_programs/services/team_invites.py b/partner_programs/services/team_invites.py new file mode 100644 index 00000000..f9c7d17a --- /dev/null +++ b/partner_programs/services/team_invites.py @@ -0,0 +1,333 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from partner_programs.models import ( + Application, + PartnerProgram, + PartnerProgramUserProfile, + Team, + TeamInvite, + TeamMember, +) +from partner_programs.permissions import can_manage_team +from partner_programs.services.application_team import ( + ActiveApplicationConflictError, + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + ApplicationTeamServiceError, + require_no_active_conflict, +) + +User = get_user_model() + + +class TeamInvitePermissionError(ApplicationTeamServiceError): + code = "team_invite_permission_denied" + default_detail = "Недостаточно прав для управления приглашениями команды." + default_field = "team" + + +class TeamInviteNotPendingError(ApplicationTeamServiceError): + code = "team_invite_not_pending" + default_detail = "Приглашение уже завершено и не может быть изменено." + default_field = "status" + + +class TeamInviteTargetInvalidError(ApplicationTeamServiceError): + code = "team_invite_target_invalid" + default_detail = "Пользователя нельзя пригласить в эту команду." + default_field = "user_id" + + +class TeamInviteRegistrationMissingError(ApplicationTeamServiceError): + code = "team_invite_registration_missing" + default_detail = "Пользователь не зарегистрирован на активность." + default_field = "user_id" + + +class TeamInviteDuplicateError(ApplicationTeamServiceError): + code = "team_invite_duplicate" + default_detail = "Активное приглашение для этого пользователя уже существует." + default_field = "user_id" + + +class TeamInviteCapacityReachedError(ApplicationTeamServiceError): + code = "team_invite_capacity_reached" + default_detail = "В команде нет свободного места для нового приглашения." + default_field = "team" + + +class TeamInviteActiveApplicationConflictError(ApplicationTeamServiceError): + code = "team_invite_active_application_conflict" + default_detail = ( + "Пользователь уже участвует в другой активной заявке этой программы." + ) + default_field = "user_id" + + +class TeamInviteNotOwnedError(ApplicationTeamServiceError): + code = "team_invite_not_owned" + default_detail = "Это приглашение предназначено другому пользователю." + default_field = "invite" + + +@dataclass(frozen=True) +class TeamInviteCreationResult: + """Результат идемпотентного создания приглашения.""" + + invite: TeamInvite + created: bool + + +def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: + """Блокирует Program → Application → Team в общем порядке domain-операций.""" + program_id = team.application.program_id + application_id = team.application_id + + 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.pk) + ) + return program, application, locked_team + + +def _lock_invite_graph( + invite: TeamInvite, +) -> tuple[PartnerProgram, Application, Team, TeamInvite]: + """Блокирует граф приглашения в том же порядке, что и операции Team.""" + program, application, team = _lock_team_graph(invite.team) + locked_invite = ( + TeamInvite.objects.select_for_update() + .select_related("team", "team__application", "user", "invited_by") + .get(pk=invite.pk) + ) + return program, application, team, locked_invite + + +def _require_mutable_invites( + *, + application: Application, + program: PartnerProgram, +) -> None: + """Разрешает приглашения только для draft-заявки до ее дедлайна.""" + if application.status != Application.STATUS_DRAFT: + raise ApplicationNotEditableError( + "Приглашения можно изменять только в черновике заявки." + ) + if program.is_application_deadline_passed(): + raise ApplicationDeadlinePassedError() + + +def _require_registered_target(*, program: PartnerProgram, user: User) -> None: + if not PartnerProgramUserProfile.objects.filter( + partner_program=program, + user=user, + ).exists(): + raise TeamInviteRegistrationMissingError() + + +def _require_no_target_conflict(*, program: PartnerProgram, user: User) -> None: + try: + require_no_active_conflict(program=program, user=user) + except ActiveApplicationConflictError as exc: + raise TeamInviteActiveApplicationConflictError() from exc + + +def _accepted_members_count(team: Team) -> int: + return TeamMember.objects.select_for_update().filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ).count() + + +def _pending_invites_count(team: Team) -> int: + return TeamInvite.objects.select_for_update().filter( + team=team, + status=TeamInvite.STATUS_PENDING, + ).count() + + +def _require_invite_capacity(*, team: Team, program: PartnerProgram) -> None: + maximum = program.team_max_size + if maximum is None or ( + _accepted_members_count(team) + _pending_invites_count(team) >= maximum + ): + raise TeamInviteCapacityReachedError() + + +def _require_accept_capacity(*, team: Team, program: PartnerProgram) -> None: + maximum = program.team_max_size + if maximum is None or _accepted_members_count(team) >= maximum: + raise TeamInviteCapacityReachedError() + + +def create_team_invite( + *, + team: Team, + actor: User, + target: User, +) -> TeamInviteCreationResult: + """Создает pending-приглашение без создания предварительного TeamMember. + + Повторный запрос для того же pending-приглашения возвращает существующую + запись. Program блокируется первым, чтобы проверки конфликтов и вместимости + оставались согласованными с параллельными операциями над другими командами. + """ + with transaction.atomic(): + program, application, team = _lock_team_graph(team) + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + _require_mutable_invites(application=application, program=program) + + if ( + application.participation_mode != Application.PARTICIPATION_MODE_TEAM + or target.pk == team.captain_id + or TeamMember.objects.select_for_update().filter( + team=team, + user=target, + status=TeamMember.STATUS_ACCEPTED, + ).exists() + ): + raise TeamInviteTargetInvalidError() + + existing = TeamInvite.objects.select_for_update().filter( + team=team, + user=target, + status=TeamInvite.STATUS_PENDING, + ).first() + if existing is not None: + return TeamInviteCreationResult(existing, created=False) + + _require_registered_target(program=program, user=target) + _require_no_target_conflict(program=program, user=target) + _require_invite_capacity(team=team, program=program) + + try: + # Вложенная транзакция сохраняет внешнюю при IntegrityError в гонке. + with transaction.atomic(): + invite = TeamInvite.objects.create( + team=team, + user=target, + invited_by=actor, + ) + except IntegrityError as exc: + invite = TeamInvite.objects.filter( + team=team, + user=target, + status=TeamInvite.STATUS_PENDING, + ).first() + if invite is None: + raise TeamInviteDuplicateError() from exc + return TeamInviteCreationResult(invite, created=False) + + return TeamInviteCreationResult(invite, created=True) + + +def accept_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Атомарно принимает приглашение и создает либо восстанавливает membership. + + Принятое приглашение идемпотентно. Только первый переход из pending повторно + проверяет Registration, конфликт участия, вместимость и открытый draft. + """ + with transaction.atomic(): + program, application, team, invite = _lock_invite_graph(invite) + if invite.user_id != actor.pk: + raise TeamInviteNotOwnedError() + if invite.status == TeamInvite.STATUS_ACCEPTED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + _require_registered_target(program=program, user=invite.user) + _require_no_target_conflict(program=program, user=invite.user) + + member = TeamMember.objects.select_for_update().filter( + team=team, + user=invite.user, + ).first() + if member is not None and member.status == TeamMember.STATUS_ACCEPTED: + raise TeamInviteTargetInvalidError( + "Пользователь уже является участником этой команды." + ) + _require_accept_capacity(team=team, program=program) + + if member is None: + member = TeamMember( + team=team, + user=invite.user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=invite.invited_by, + ) + else: + member.role = TeamMember.ROLE_MEMBER + member.status = TeamMember.STATUS_ACCEPTED + member.invited_by = invite.invited_by + member.save() + + resolved_at = timezone.now() + invite.status = TeamInvite.STATUS_ACCEPTED + invite.resolved_at = resolved_at + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + + # Принятие места в одной команде закрывает конкурирующие pending-инвайты + # этого пользователя в рамках той же Program, но сохраняет их историю. + other_invites = TeamInvite.objects.select_for_update().filter( + user=invite.user, + status=TeamInvite.STATUS_PENDING, + team__application__program=program, + ).exclude(pk=invite.pk) + list(other_invites.values_list("pk", flat=True)) + other_invites.update( + status=TeamInvite.STATUS_REVOKED, + resolved_at=resolved_at, + updated_at=resolved_at, + ) + return invite + + +def decline_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Отклоняет принадлежащее пользователю pending-приглашение.""" + with transaction.atomic(): + program, application, _team, invite = _lock_invite_graph(invite) + if invite.user_id != actor.pk: + raise TeamInviteNotOwnedError() + if invite.status == TeamInvite.STATUS_DECLINED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + invite.status = TeamInvite.STATUS_DECLINED + invite.resolved_at = timezone.now() + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + return invite + + +def revoke_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: + """Отзывает pending-приглашение капитаном команды или staff.""" + with transaction.atomic(): + program, application, team, invite = _lock_invite_graph(invite) + if not can_manage_team(actor, team): + raise TeamInvitePermissionError() + if invite.status == TeamInvite.STATUS_REVOKED: + return invite + if invite.status != TeamInvite.STATUS_PENDING: + raise TeamInviteNotPendingError() + + _require_mutable_invites(application=application, program=program) + invite.status = TeamInvite.STATUS_REVOKED + invite.resolved_at = timezone.now() + invite.save(update_fields=["status", "resolved_at", "updated_at"]) + return invite diff --git a/partner_programs/team_invite_urls.py b/partner_programs/team_invite_urls.py new file mode 100644 index 00000000..e165fa50 --- /dev/null +++ b/partner_programs/team_invite_urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from partner_programs.team_invite_views import ( + MyTeamInviteListView, + TeamInviteAcceptView, + TeamInviteDeclineView, + TeamInviteRevokeView, +) + +app_name = "team_invites" + +urlpatterns = [ + path("my/", MyTeamInviteListView.as_view(), name="my-list"), + path("/accept/", TeamInviteAcceptView.as_view(), name="accept"), + path( + "/decline/", + TeamInviteDeclineView.as_view(), + name="decline", + ), + path("/revoke/", TeamInviteRevokeView.as_view(), name="revoke"), +] diff --git a/partner_programs/team_invite_views.py b/partner_programs/team_invite_views.py new file mode 100644 index 00000000..3fbb4f46 --- /dev/null +++ b/partner_programs/team_invite_views.py @@ -0,0 +1,203 @@ +from django.contrib.auth import get_user_model +from django.db.models import Case, IntegerField, Value, When +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, TeamInvite +from partner_programs.permissions import can_manage_team, can_view_team +from partner_programs.serializers import ( + MyTeamInviteSerializer, + TeamInviteCreateSerializer, + TeamInviteSerializer, +) +from partner_programs.services.application_team import ApplicationTeamServiceError +from partner_programs.services.team_invites import ( + TeamInviteNotOwnedError, + TeamInvitePermissionError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + revoke_team_invite, +) +from partner_programs.throttling import TeamInviteMutationScopedRateThrottle + +User = get_user_model() + + +def _team_queryset(): + return Team.objects.select_related( + "application", + "application__program", + "application__user", + "captain", + ) + + +def _invite_queryset(): + return TeamInvite.objects.select_related( + "team", + "team__application", + "team__application__program", + "team__captain", + "user", + "invited_by", + ) + + +def _get_team(application_id: int) -> Team: + return get_object_or_404(_team_queryset(), application_id=application_id) + + +def _get_invite(invite_id: int) -> TeamInvite: + return get_object_or_404(_invite_queryset(), pk=invite_id) + + +def _raise_domain_error(exc: ApplicationTeamServiceError): + if isinstance(exc, (TeamInvitePermissionError, TeamInviteNotOwnedError)): + raise PermissionDenied(exc.detail, code=exc.code) from exc + raise ValidationError({exc.field: exc.detail}, code=exc.code) from exc + + +def _require_invite_manager(user, team: Team) -> None: + # Незнакомому пользователю скрываем существование Team; участник и manager + # знают команду, но получают явный запрет на закрытый список приглашений. + if not can_view_team(user, team): + raise NotFound("Team not found.") + if not can_manage_team(user, team): + raise PermissionDenied( + TeamInvitePermissionError.default_detail, + code=TeamInvitePermissionError.code, + ) + + +class TeamInviteListCreateView(APIView): + """Возвращает историю приглашений и создает pending для капитана/staff.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + invites = _invite_queryset().filter(team=team).order_by("-created_at") + return Response(TeamInviteSerializer(invites, many=True).data) + + def post(self, request, application_id): + team = _get_team(application_id) + _require_invite_manager(request.user, team) + serializer = TeamInviteCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + target = User.objects.filter(pk=serializer.validated_data["user_id"]).first() + if target is None: + _raise_domain_error(TeamInviteTargetInvalidError()) + + try: + result = create_team_invite( + team=team, + actor=request.user, + target=target, + ) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + + response_status = ( + status.HTTP_201_CREATED if result.created else status.HTTP_200_OK + ) + return Response( + TeamInviteSerializer(result.invite).data, + status=response_status, + ) + + def get_throttles(self): + if self.request.method == "POST": + self.throttle_scope = "team_invite_create" + return [TeamInviteMutationScopedRateThrottle()] + return [] + + +class MyTeamInviteListView(APIView): + """Возвращает только приглашения текущего пользователя: pending первыми.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + invites = ( + _invite_queryset() + .filter(user=request.user) + .annotate( + pending_order=Case( + When(status=TeamInvite.STATUS_PENDING, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("pending_order", "-created_at") + ) + return Response(MyTeamInviteSerializer(invites, many=True).data) + + +class TeamInviteAcceptView(APIView): + """Принимает приглашение адресатом через атомарный domain service.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_accept" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + if invite.user_id != request.user.pk: + if can_manage_team(request.user, invite.team): + raise PermissionDenied( + TeamInviteNotOwnedError.default_detail, + code=TeamInviteNotOwnedError.code, + ) + raise NotFound("Team invite not found.") + try: + invite = accept_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(MyTeamInviteSerializer(invite).data) + + +class TeamInviteDeclineView(APIView): + """Отклоняет приглашение адресатом без создания TeamMember.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_decline" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + if invite.user_id != request.user.pk: + if can_manage_team(request.user, invite.team): + raise PermissionDenied( + TeamInviteNotOwnedError.default_detail, + code=TeamInviteNotOwnedError.code, + ) + raise NotFound("Team invite not found.") + try: + invite = decline_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(MyTeamInviteSerializer(invite).data) + + +class TeamInviteRevokeView(APIView): + """Отзывает приглашение капитаном команды или staff.""" + + permission_classes = [IsAuthenticated] + throttle_classes = [TeamInviteMutationScopedRateThrottle] + throttle_scope = "team_invite_revoke" + + def post(self, request, invite_id): + invite = _get_invite(invite_id) + _require_invite_manager(request.user, invite.team) + try: + invite = revoke_team_invite(invite=invite, actor=request.user) + except ApplicationTeamServiceError as exc: + _raise_domain_error(exc) + return Response(TeamInviteSerializer(invite).data) diff --git a/partner_programs/tests/test_team_invite_api.py b/partner_programs/tests/test_team_invite_api.py new file mode 100644 index 00000000..dc4a2dc9 --- /dev/null +++ b/partner_programs/tests/test_team_invite_api.py @@ -0,0 +1,417 @@ +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, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import create_or_get_application +from partner_programs.services.team_invites import create_team_invite +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) +from partner_programs.throttling import TeamInviteMutationScopedRateThrottle + + +class TeamInviteAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.captain = create_user(prefix="invite-api-captain") + self.target = create_user(prefix="invite-api-target") + self.member_user = create_user(prefix="invite-api-member") + self.manager = create_user(prefix="invite-api-manager") + self.staff = create_user(prefix="invite-api-staff", is_staff=True) + self.outsider = create_user(prefix="invite-api-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=6, + ) + self.program.managers.add(self.manager) + for user in (self.captain, self.target, self.member_user): + create_program_member(self.program, user=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 list_url(self): + return f"/applications/{self.application.pk}/team/invites/" + + @staticmethod + def action_url(invite, action): + return f"/team-invites/{invite.pk}/{action}/" + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def create_target(self, prefix="invite-api-extra"): + user = create_user(prefix=prefix) + create_program_member(self.program, user=user) + return user + + def create_invite(self, *, target=None, actor=None): + return create_team_invite( + team=self.team, + actor=actor or self.captain, + target=target or self.target, + ).invite + + def test_anonymous_user_cannot_use_invite_endpoints(self): + invite = self.create_invite() + + self.assertEqual(self.client.get(self.list_url).status_code, 401) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 401, + ) + self.assertEqual(self.client.get("/team-invites/my/").status_code, 401) + for action in ("accept", "decline", "revoke"): + with self.subTest(action=action): + self.assertEqual( + self.client.post(self.action_url(invite, action)).status_code, + 401, + ) + + def test_captain_and_staff_can_list_but_member_manager_cannot(self): + self.create_invite() + for user in (self.captain, self.staff): + self.authenticate(user) + with self.subTest(allowed=user): + self.assertEqual(self.client.get(self.list_url).status_code, 200) + + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(forbidden=user): + self.assertEqual(self.client.get(self.list_url).status_code, 403) + + self.authenticate(self.outsider) + self.assertEqual(self.client.get(self.list_url).status_code, 404) + + def test_create_is_idempotent_and_exposes_only_safe_user_fields(self): + self.authenticate(self.captain) + first = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + second = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["id"], second.data["id"]) + self.assertEqual( + set(first.data["user"]), + {"id", "display_name", "avatar"}, + ) + self.assertEqual( + set(first.data["invited_by"]), + {"id", "display_name", "avatar"}, + ) + self.assertNotIn("email", first.data["user"]) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) + + def test_member_and_manager_cannot_create_outsider_gets_hidden_404(self): + for user in (self.member_user, self.manager): + self.authenticate(user) + with self.subTest(user=user): + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 403, + ) + + self.authenticate(self.outsider) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 404, + ) + + def test_staff_can_create_invite(self): + self.authenticate(self.staff) + + response = self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["invited_by"]["id"], self.staff.pk) + + def test_unknown_target_and_domain_state_errors_return_400(self): + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": 99999999}, + format="json", + ).status_code, + 400, + ) + + self.program.team_max_size = 2 + self.program.save(update_fields=["team_max_size"]) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": self.target.pk}, + format="json", + ).status_code, + 400, + ) + + def test_my_invites_returns_only_current_user_pending_first(self): + resolved = TeamInvite.objects.create( + team=self.team, + user=self.target, + invited_by=self.captain, + status=TeamInvite.STATUS_DECLINED, + resolved_at=timezone.now(), + ) + pending = self.create_invite() + other_target = self.create_target() + self.create_invite(target=other_target) + self.authenticate(self.target) + + response = self.client.get("/team-invites/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.data], [pending.pk, resolved.pk]) + self.assertEqual(response.data[0]["application_id"], self.application.pk) + self.assertEqual(response.data[0]["program"]["id"], self.program.pk) + self.assertEqual(response.data[0]["captain"]["id"], self.captain.pk) + self.assertTrue(response.data[0]["can_accept"]) + self.assertTrue(response.data[0]["can_decline"]) + + def test_pending_invite_does_not_grant_access_but_accept_does(self): + invite = self.create_invite() + submission = Submission.objects.create( + application=self.application, + program=self.program, + submitted_by=self.captain, + title="Закрытое решение", + ) + application_url = f"/applications/{self.application.pk}/" + team_url = f"/applications/{self.application.pk}/team/" + submission_url = f"/submissions/{submission.pk}/" + self.authenticate(self.target) + + self.assertEqual(self.client.get(application_url).status_code, 404) + self.assertEqual(self.client.get(team_url).status_code, 404) + self.assertEqual(self.client.get(submission_url).status_code, 404) + + accepted = self.client.post(self.action_url(invite, "accept"), {}, format="json") + + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.data["status"], TeamInvite.STATUS_ACCEPTED) + self.assertEqual(self.client.get(application_url).status_code, 200) + self.assertEqual(self.client.get(team_url).status_code, 200) + self.assertEqual(self.client.get(submission_url).status_code, 200) + self.assertEqual( + self.client.patch(application_url, {"form_data": {}}, format="json").status_code, + 403, + ) + self.assertEqual( + self.client.patch(team_url, {"name": "Нельзя"}, format="json").status_code, + 403, + ) + self.assertEqual( + self.client.patch(submission_url, {"title": "Нельзя"}, format="json").status_code, + 403, + ) + + def test_accept_decline_and_revoke_actions(self): + accepted_invite = self.create_invite() + self.authenticate(self.target) + accepted = self.client.post( + self.action_url(accepted_invite, "accept"), + {}, + format="json", + ) + self.assertEqual(accepted.status_code, 200) + self.assertEqual(accepted.data["status"], TeamInvite.STATUS_ACCEPTED) + + decline_target = self.create_target(prefix="invite-api-decline") + decline_invite = self.create_invite(target=decline_target) + self.authenticate(decline_target) + declined = self.client.post( + self.action_url(decline_invite, "decline"), + {}, + format="json", + ) + self.assertEqual(declined.status_code, 200) + self.assertEqual(declined.data["status"], TeamInvite.STATUS_DECLINED) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=decline_target).exists() + ) + + revoke_target = self.create_target(prefix="invite-api-revoke") + revoke_invite = self.create_invite(target=revoke_target) + self.authenticate(self.captain) + revoked = self.client.post( + self.action_url(revoke_invite, "revoke"), + {}, + format="json", + ) + self.assertEqual(revoked.status_code, 200) + self.assertEqual(revoked.data["status"], TeamInvite.STATUS_REVOKED) + + def test_action_permission_boundaries_are_404_or_403(self): + invite = self.create_invite() + self.authenticate(self.outsider) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 404, + ) + + self.authenticate(self.captain) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 403, + ) + + self.authenticate(self.target) + declined = self.client.post(self.action_url(invite, "decline")) + self.assertEqual(declined.status_code, 200) + self.assertEqual( + self.client.post(self.action_url(invite, "accept")).status_code, + 400, + ) + self.assertEqual(self.client.post("/team-invites/999999/accept/").status_code, 404) + + def test_invite_mutations_are_blocked_after_application_submit(self): + accept_target = self.target + decline_target = self.create_target(prefix="invite-api-locked-decline") + revoke_target = self.create_target(prefix="invite-api-locked-revoke") + accept_invite = self.create_invite(target=accept_target) + decline_invite = self.create_invite(target=decline_target) + revoke_invite = self.create_invite(target=revoke_target) + create_target = self.create_target(prefix="invite-api-locked-create") + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + + self.authenticate(self.captain) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": create_target.pk}, + format="json", + ).status_code, + 400, + ) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 400, + ) + self.authenticate(accept_target) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 400, + ) + self.authenticate(decline_target) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 400, + ) + + @patch.object(TeamInviteMutationScopedRateThrottle, "rate", "1/min") + def test_create_has_own_throttle_and_get_is_not_throttled(self): + self.authenticate(self.captain) + first_target = self.target + second_target = self.create_target(prefix="invite-api-throttle-create") + + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": first_target.pk}, + format="json", + ).status_code, + 201, + ) + self.assertEqual( + self.client.post( + self.list_url, + {"user_id": second_target.pk}, + format="json", + ).status_code, + 429, + ) + self.assertEqual(self.client.get(self.list_url).status_code, 200) + self.assertEqual(self.client.get(self.list_url).status_code, 200) + + @patch.object(TeamInviteMutationScopedRateThrottle, "rate", "1/min") + def test_accept_decline_and_revoke_have_separate_throttle_scopes(self): + accept_invite = self.create_invite() + self.authenticate(self.target) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(accept_invite, "accept")).status_code, + 429, + ) + + decline_target = self.create_target(prefix="invite-api-throttle-decline") + decline_invite = self.create_invite(target=decline_target) + self.authenticate(decline_target) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(decline_invite, "decline")).status_code, + 429, + ) + + revoke_target = self.create_target(prefix="invite-api-throttle-revoke") + revoke_invite = self.create_invite(target=revoke_target) + self.authenticate(self.captain) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 200, + ) + self.assertEqual( + self.client.post(self.action_url(revoke_invite, "revoke")).status_code, + 429, + ) diff --git a/partner_programs/tests/test_team_invite_model.py b/partner_programs/tests/test_team_invite_model.py new file mode 100644 index 00000000..aadeb58e --- /dev/null +++ b/partner_programs/tests/test_team_invite_model.py @@ -0,0 +1,84 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from partner_programs.models import ( + Application, + PartnerProgram, + TeamInvite, + 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, +) + + +class TeamInviteModelTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="invite-model-captain") + self.target = create_user(prefix="invite-model-target") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.target) + 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 + + def create_invite(self, **overrides): + defaults = { + "team": self.team, + "user": self.target, + "invited_by": self.captain, + } + defaults.update(overrides) + return TeamInvite.objects.create(**defaults) + + def test_statuses_and_default(self): + invite = self.create_invite() + + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + self.assertEqual( + {value for value, _label in TeamInvite.STATUS_CHOICES}, + {"pending", "accepted", "declined", "revoked"}, + ) + + def test_only_one_pending_invite_per_team_and_user(self): + self.create_invite() + + with self.assertRaises(IntegrityError), transaction.atomic(): + self.create_invite() + + def test_resolved_invite_allows_new_pending_invite(self): + resolved = self.create_invite(status=TeamInvite.STATUS_DECLINED) + pending = self.create_invite() + + self.assertNotEqual(resolved.pk, pending.pk) + self.assertEqual( + TeamInvite.objects.filter(team=self.team, user=self.target).count(), + 2, + ) + + def test_timestamps_are_filled(self): + invite = self.create_invite() + + self.assertIsNotNone(invite.created_at) + self.assertIsNotNone(invite.updated_at) + self.assertIsNone(invite.resolved_at) + + def test_creating_invite_does_not_create_team_member(self): + self.create_invite() + + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) diff --git a/partner_programs/tests/test_team_invite_service.py b/partner_programs/tests/test_team_invite_service.py new file mode 100644 index 00000000..491c0111 --- /dev/null +++ b/partner_programs/tests/test_team_invite_service.py @@ -0,0 +1,330 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import ( + Application, + PartnerProgram, + TeamInvite, + TeamMember, +) +from partner_programs.services.application_team import ( + ApplicationDeadlinePassedError, + ApplicationNotEditableError, + TeamHasOtherMembersError, + change_application_participation_mode, + create_or_get_application, + submit_application, +) +from partner_programs.services.team_invites import ( + TeamInviteActiveApplicationConflictError, + TeamInviteCapacityReachedError, + TeamInviteNotOwnedError, + TeamInviteNotPendingError, + TeamInvitePermissionError, + TeamInviteRegistrationMissingError, + TeamInviteTargetInvalidError, + accept_team_invite, + create_team_invite, + decline_team_invite, + revoke_team_invite, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class TeamInviteServiceTests(TestCase): + def setUp(self): + self.captain = create_user(prefix="invite-service-captain") + self.target = create_user(prefix="invite-service-target") + self.outsider = create_user(prefix="invite-service-outsider") + self.program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=4, + ) + create_program_member(self.program, user=self.captain) + create_program_member(self.program, user=self.target) + self.application = self.create_team_application(self.captain, "Команда один") + self.team = self.application.team + + def create_team_application(self, captain, name): + if not self.program.partner_program_profiles.filter(user=captain).exists(): + create_program_member(self.program, user=captain) + return create_or_get_application( + program=self.program, + user=captain, + created_by=captain, + participation_mode=Application.PARTICIPATION_MODE_TEAM, + team_name=name, + ).application + + def create_pending_invite(self, *, team=None, target=None, actor=None): + return create_team_invite( + team=team or self.team, + actor=actor or self.captain, + target=target or self.target, + ).invite + + def test_create_is_successful_idempotent_and_does_not_create_member(self): + first = create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + second = create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + + self.assertTrue(first.created) + self.assertFalse(second.created) + self.assertEqual(first.invite.pk, second.invite.pk) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) + + def test_only_captain_or_staff_can_create_and_revoke(self): + with self.assertRaises(TeamInvitePermissionError): + create_team_invite( + team=self.team, + actor=self.outsider, + target=self.target, + ) + + staff = create_user(prefix="invite-service-staff", is_staff=True) + invite = create_team_invite( + team=self.team, + actor=staff, + target=self.target, + ).invite + revoked = revoke_team_invite(invite=invite, actor=staff) + self.assertEqual(revoked.status, TeamInvite.STATUS_REVOKED) + + def test_target_must_not_be_captain_or_accepted_member(self): + with self.assertRaises(TeamInviteTargetInvalidError): + create_team_invite( + team=self.team, + actor=self.captain, + target=self.captain, + ) + + TeamMember.objects.create( + team=self.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + with self.assertRaises(TeamInviteTargetInvalidError): + create_team_invite( + team=self.team, + actor=self.captain, + target=self.target, + ) + + def test_registration_is_required(self): + unregistered = create_user(prefix="invite-unregistered") + + with self.assertRaises(TeamInviteRegistrationMissingError): + create_team_invite( + team=self.team, + actor=self.captain, + target=unregistered, + ) + + def test_active_own_application_is_a_conflict(self): + Application.objects.create( + program=self.program, + user=self.target, + created_by=self.target, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + self.create_pending_invite() + + def test_accepted_membership_in_other_team_is_a_conflict(self): + other_captain = create_user(prefix="invite-other-captain") + other_application = self.create_team_application(other_captain, "Другая команда") + TeamMember.objects.create( + team=other_application.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=other_captain, + ) + + with self.assertRaises(TeamInviteActiveApplicationConflictError): + self.create_pending_invite() + + def test_pending_invites_reserve_capacity(self): + self.program.team_max_size = 2 + self.program.save(update_fields=["team_max_size"]) + self.create_pending_invite() + second_target = create_user(prefix="invite-capacity-target") + create_program_member(self.program, user=second_target) + + with self.assertRaises(TeamInviteCapacityReachedError): + self.create_pending_invite(target=second_target) + + def test_deadline_and_non_draft_application_block_mutations(self): + self.program.datetime_application_ends = ( + timezone.now() - timezone.timedelta(seconds=1) + ) + self.program.save(update_fields=["datetime_application_ends"]) + with self.assertRaises(ApplicationDeadlinePassedError): + self.create_pending_invite() + + self.program.datetime_application_ends = None + self.program.save(update_fields=["datetime_application_ends"]) + self.application.status = Application.STATUS_SUBMITTED + self.application.save(update_fields=["status", "updated_at"]) + with self.assertRaises(ApplicationNotEditableError): + self.create_pending_invite() + + def test_pending_invite_blocks_switch_to_individual(self): + self.program.participation_format = ( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ) + self.program.save(update_fields=["participation_format"]) + self.create_pending_invite() + + with self.assertRaises(TeamHasOtherMembersError): + change_application_participation_mode( + application=self.application, + actor=self.captain, + participation_mode=Application.PARTICIPATION_MODE_INDIVIDUAL, + ) + + def test_pending_invite_is_not_counted_as_member_on_submit(self): + accepted_user = create_user(prefix="invite-submit-member") + create_program_member(self.program, user=accepted_user) + TeamMember.objects.create( + team=self.team, + user=accepted_user, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_ACCEPTED, + invited_by=self.captain, + ) + invite = self.create_pending_invite() + + submitted = submit_application( + application=self.application, + actor=self.captain, + ) + + invite.refresh_from_db() + self.assertEqual(submitted.status, Application.STATUS_SUBMITTED) + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + + def test_accept_creates_member_and_is_idempotent(self): + invite = self.create_pending_invite() + + accepted = accept_team_invite(invite=invite, actor=self.target) + repeated = accept_team_invite(invite=accepted, actor=self.target) + + self.assertEqual(repeated.status, TeamInvite.STATUS_ACCEPTED) + self.assertIsNotNone(repeated.resolved_at) + members = TeamMember.objects.filter(team=self.team, user=self.target) + self.assertEqual(members.count(), 1) + self.assertEqual(members.get().status, TeamMember.STATUS_ACCEPTED) + + def test_accept_restores_historical_member_and_preserves_joined_at(self): + joined_at = timezone.now() - timezone.timedelta(days=10) + member = TeamMember.objects.create( + team=self.team, + user=self.target, + role=TeamMember.ROLE_MEMBER, + status=TeamMember.STATUS_LEFT, + invited_by=self.captain, + joined_at=joined_at, + ) + invite = self.create_pending_invite() + + accept_team_invite(invite=invite, actor=self.target) + + member.refresh_from_db() + self.assertEqual(member.status, TeamMember.STATUS_ACCEPTED) + self.assertEqual(member.role, TeamMember.ROLE_MEMBER) + self.assertEqual(member.joined_at, joined_at) + self.assertEqual( + TeamMember.objects.filter(team=self.team, user=self.target).count(), + 1, + ) + + def test_only_invitee_can_accept_or_decline(self): + invite = self.create_pending_invite() + + with self.assertRaises(TeamInviteNotOwnedError): + accept_team_invite(invite=invite, actor=self.outsider) + with self.assertRaises(TeamInviteNotOwnedError): + decline_team_invite(invite=invite, actor=self.outsider) + + def test_decline_and_revoke_are_idempotent(self): + declined = decline_team_invite( + invite=self.create_pending_invite(), + actor=self.target, + ) + repeated_decline = decline_team_invite( + invite=declined, + actor=self.target, + ) + self.assertEqual(repeated_decline.status, TeamInvite.STATUS_DECLINED) + self.assertIsNotNone(repeated_decline.resolved_at) + + second_target = create_user(prefix="invite-revoke-target") + create_program_member(self.program, user=second_target) + revoked = revoke_team_invite( + invite=self.create_pending_invite(target=second_target), + actor=self.captain, + ) + repeated_revoke = revoke_team_invite( + invite=revoked, + actor=self.captain, + ) + self.assertEqual(repeated_revoke.status, TeamInvite.STATUS_REVOKED) + + def test_other_terminal_transitions_return_stable_error(self): + declined = decline_team_invite( + invite=self.create_pending_invite(), + actor=self.target, + ) + + with self.assertRaises(TeamInviteNotPendingError): + accept_team_invite(invite=declined, actor=self.target) + with self.assertRaises(TeamInviteNotPendingError): + revoke_team_invite(invite=declined, actor=self.captain) + + def test_accept_revokes_other_pending_invites_in_same_program(self): + first = self.create_pending_invite() + other_captain = create_user(prefix="invite-second-captain") + other_application = self.create_team_application(other_captain, "Команда два") + second = self.create_pending_invite( + team=other_application.team, + target=self.target, + actor=other_captain, + ) + + accept_team_invite(invite=first, actor=self.target) + + second.refresh_from_db() + self.assertEqual(second.status, TeamInvite.STATUS_REVOKED) + self.assertIsNotNone(second.resolved_at) + + def test_accept_rolls_back_membership_if_invite_update_fails(self): + invite = self.create_pending_invite() + + with patch.object(TeamInvite, "save", side_effect=RuntimeError("failure")): + with self.assertRaises(RuntimeError): + accept_team_invite(invite=invite, actor=self.target) + + invite.refresh_from_db() + self.assertEqual(invite.status, TeamInvite.STATUS_PENDING) + self.assertFalse( + TeamMember.objects.filter(team=self.team, user=self.target).exists() + ) diff --git a/partner_programs/throttling.py b/partner_programs/throttling.py index 3b9c6164..c9cf95c0 100644 --- a/partner_programs/throttling.py +++ b/partner_programs/throttling.py @@ -8,3 +8,12 @@ class TeamMutationScopedRateThrottle(ScopedRateThrottle): def get_rate(self): return self.rate + + +class TeamInviteMutationScopedRateThrottle(ScopedRateThrottle): + """Ограничивает mutation приглашений без глобального DRF throttling.""" + + rate = "20/min" + + def get_rate(self): + return self.rate diff --git a/procollab/urls.py b/procollab/urls.py index 9b7f1309..9a807a41 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -63,6 +63,10 @@ "submissions/", include("partner_programs.submission_urls", namespace="submissions"), ), + path( + "team-invites/", + include("partner_programs.team_invite_urls", namespace="team_invites"), + ), path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")),