From 3f52b9a782973d308e4f40343e80937eac018476 Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 6 Aug 2026 16:47:38 +0300 Subject: [PATCH] Add project invitation API --- docs/modules/invites.md | 5 + docs/project-invitations-workspace-api.md | 179 +++++++ invites/admin.py | 3 + invites/managers.py | 13 +- ...e_invited_by_invite_is_revoked_and_more.py | 111 ++++ invites/models.py | 69 ++- invites/serializers.py | 5 +- .../test_project_workspace_invitation_api.py | 502 ++++++++++++++++++ invites/views.py | 45 +- invites/workspace_serializers.py | 106 ++++ invites/workspace_services.py | 189 +++++++ invites/workspace_views.py | 227 ++++++++ projects/urls.py | 32 ++ 13 files changed, 1457 insertions(+), 29 deletions(-) create mode 100644 docs/project-invitations-workspace-api.md create mode 100644 invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py create mode 100644 invites/tests/test_project_workspace_invitation_api.py create mode 100644 invites/workspace_serializers.py create mode 100644 invites/workspace_services.py create mode 100644 invites/workspace_views.py diff --git a/docs/modules/invites.md b/docs/modules/invites.md index 48efddfa..e13f4dfe 100644 --- a/docs/modules/invites.md +++ b/docs/modules/invites.md @@ -11,6 +11,11 @@ Invites отвечает за приглашения пользователей Модуль рабочий и подключен в публичный API через `/invites/`. +Для React workspace DEV-079.1 добавлен отдельный project-scoped контракт поверх +той же модели `Invite`. Он описан в +[`docs/project-invitations-workspace-api.md`](../project-invitations-workspace-api.md) +и не меняет URL старого Angular-клиента. + Приглашения доступны приглашенному пользователю, лидеру проекта и staff/superuser. Изменять или удалять приглашение может лидер проекта. Принять или отклонить приглашение может приглашенный пользователь. diff --git a/docs/project-invitations-workspace-api.md b/docs/project-invitations-workspace-api.md new file mode 100644 index 00000000..6374c0e6 --- /dev/null +++ b/docs/project-invitations-workspace-api.md @@ -0,0 +1,179 @@ +# Project Invitations Workspace API + +## Назначение + +DEV-079.1 добавляет безопасный backend-сценарий приглашения зарегистрированного +пользователя в рабочее пространство `Project`. После принятия пользователь +становится `Collaborator` этого проекта. `TeamMember`, участник Application, +подписчик проекта и `Collaborator` остаются разными сущностями. + +React-интерфейс будет добавлен отдельным этапом DEV-079.2. В этом PR не +добавляются email-приглашения, токены, сроки действия и приглашения +незарегистрированных пользователей. + +## Аудит legacy-сценария + +Существующая модель `invites.Invite` уже хранит связь `Project` и +зарегистрированного `CustomUser`, роль, специализацию, сообщение и tri-state +`is_accepted`. Angular передает числовой user id, извлеченный из ссылки на +профиль, в `POST /invites/`, получает активные приглашения через +`GET /invites/`, принимает и отклоняет их отдельными action endpoints, а +`DELETE /invites//` использует как отзыв. + +Legacy-контракт `/invites/` сохранен. Новый workspace API использует ту же +сущность, но не требует от React знания legacy-полей `project`, `user` и +`is_accepted`. + +## Lifecycle и миграция + +В `Invite` добавлены: + +- `invited_by` — зарегистрированный отправитель; +- `is_revoked` — отзыв без физического удаления; +- `resolved_at` — время принятия, отклонения или отзыва. + +Публичный `status` вычисляется без удаления legacy `is_accepted`: + +- `pending` — `is_accepted=null`, `is_revoked=false`; +- `accepted` — `is_accepted=true`; +- `declined` — `is_accepted=false`; +- `revoked` — `is_revoked=true`. + +Допустимые переходы: + +```text +pending -> accepted +pending -> declined +pending -> revoked +``` + +Повторный переход завершенного приглашения возвращает `409`. Миграция заполняет +`invited_by` текущим лидером проекта, переносит дату обработки legacy-записей и, +если в старых данных есть несколько pending-записей одной пары Project/User, +оставляет активной только новейшую. Остальные сохраняются как `revoked`. + +На уровне БД действуют: + +- partial unique constraint для одной pending-записи на `project + user`; +- check constraint, запрещающий одновременно `is_revoked=true` и принятое или + отклоненное значение `is_accepted`; +- индексы списков по пользователю/проекту и lifecycle-полям. + +## API + +Все endpoints требуют аутентификацию. + +### Список и создание + +```http +GET /projects//workspace/invitations/ +POST /projects//workspace/invitations/ +``` + +Список содержит историю только указанного проекта. Доступ имеют лидер проекта, +staff и superuser. + +Payload создания: + +```json +{ + "recipient_id": 42, + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту" +} +``` + +Обязателен только `recipient_id`. Project берется из URL, отправитель — из +аутентифицированного пользователя. Неизвестные и read-only поля отклоняются. + +### Входящие приглашения + +```http +GET /projects/workspace/invitations/incoming/ +``` + +Возвращает только историю текущего получателя; pending-приглашения идут первыми. + +### Решение и отзыв + +```http +POST /projects/workspace/invitations//accept/ +POST /projects/workspace/invitations//decline/ +POST /projects//workspace/invitations//revoke/ +``` + +Action payload должен быть пустым. Принять или отклонить приглашение может +только получатель. Отозвать pending-приглашение может лидер соответствующего +Project, staff или superuser. + +Коды ответа: `201` для создания, `200` для списков и успешных переходов, `400` +для невалидных полей или получателя, `403` для видимого Project без права +управления, безопасный `404` для скрытого Project/чужого приглашения и `409` для +активного дубля либо повторного перехода статуса. + +Пример ответа: + +```json +{ + "id": 7, + "project": { + "id": 10, + "name": "Проект", + "draft": true, + "is_public": false + }, + "sender": { + "id": 1, + "first_name": "Анна", + "last_name": "Иванова", + "avatar": null + }, + "recipient": { + "id": 42, + "first_name": "Иван", + "last_name": "Петров", + "avatar": null + }, + "status": "pending", + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту", + "created_at": "2026-08-06T12:00:00Z", + "processed_at": null, + "updated_at": "2026-08-06T12:00:00Z" +} +``` + +## Права и ограничения + +| Действие | Лидер | Collaborator | Получатель | Посторонний | Staff | +|---|---:|---:|---:|---:|---:| +| Список проекта | Да | Нет | Нет | Нет | Да | +| Создание | Да | Нет | Нет | Нет | Да | +| Входящие | Только свои | Только свои | Только свои | Только свои | Только свои | +| Accept/decline | Только если получатель | Только если получатель | Да | Нет | Только если получатель | +| Revoke | Да | Нет | Нет | Нет | Да | + +Нельзя пригласить лидера, существующего `Collaborator`, неактивного или +несуществующего пользователя. Для legacy Project, напрямую связанного с +`PartnerProgramProject`, получатель должен быть участником этой программы — это +повторяет действующий invariant `Collaborator.clean()`. + +Приватный Project скрывается от постороннего через `404`; пользователь, который +видит Project, но не управляет им, получает `403`. Ответы не содержат email, +телефон, анкету Application или данные Submission. + +Создание, принятие, отклонение и отзыв выполняются в `transaction.atomic` с +блокировками Project и Invite. Принятие и создание `Collaborator` — одна +транзакция. Partial unique constraint закрывает гонку двух создающих запросов. + +## Ограничения DEV-079.1 + +- приглашаются только существующие активные пользователи по `recipient_id`; +- отдельный безопасный поиск кандидатов не добавлен; +- email, уведомления, invite links и expiration отсутствуют; +- React UI относится к DEV-079.2; +- Angular продолжает использовать legacy `/invites/`; +- `TeamInvite`, `TeamMember`, Application, Submission, подписки, цели и + достижения не изменяются. diff --git a/invites/admin.py b/invites/admin.py index 30527640..d1004da0 100644 --- a/invites/admin.py +++ b/invites/admin.py @@ -8,8 +8,11 @@ class InviteAdmin(admin.ModelAdmin): fields = [ "project", "user", + "invited_by", "motivational_letter", "role", "specialization", "is_accepted", + "is_revoked", + "resolved_at", ] diff --git a/invites/managers.py b/invites/managers.py index e71d0a6d..b70b959a 100644 --- a/invites/managers.py +++ b/invites/managers.py @@ -3,4 +3,15 @@ class InviteManager(Manager): def get_invite_for_list_view(self): - return self.get_queryset().select_related("project", "project__leader", "user") + return self.get_queryset().select_related( + "project", + "project__leader", + "user", + "invited_by", + ) + + def pending(self): + return self.get_queryset().filter( + is_accepted__isnull=True, + is_revoked=False, + ) diff --git a/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py b/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py new file mode 100644 index 00000000..975c0f52 --- /dev/null +++ b/invites/migrations/0003_invite_invited_by_invite_is_revoked_and_more.py @@ -0,0 +1,111 @@ +# Generated by Django 4.2.11 on 2026-08-06 13:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def populate_invitation_lifecycle(apps, schema_editor): + """Сохраняет legacy-состояния и устраняет активные дубли до constraint.""" + Invite = apps.get_model("invites", "Invite") + database = schema_editor.connection.alias + seen_pending = set() + changed = [] + + invitations = ( + Invite.objects.using(database) + .select_related("project") + .all() + .order_by("project_id", "user_id", "-datetime_created", "-id") + ) + for invitation in invitations.iterator(): + invitation.invited_by_id = invitation.project.leader_id + if invitation.is_accepted is not None: + invitation.resolved_at = invitation.datetime_updated + else: + key = (invitation.project_id, invitation.user_id) + if key in seen_pending: + # Новейшее pending-приглашение остается активным, предыдущие + # сохраняются в истории как отозванные. + invitation.is_revoked = True + invitation.resolved_at = invitation.datetime_updated + else: + seen_pending.add(key) + changed.append(invitation) + + if changed: + Invite.objects.using(database).bulk_update( + changed, + ["invited_by", "is_revoked", "resolved_at"], + batch_size=500, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("invites", "0002_invite_specialization"), + ] + + operations = [ + migrations.AddField( + model_name="invite", + name="invited_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sent_project_invites", + to=settings.AUTH_USER_MODEL, + verbose_name="Кем приглашен", + ), + ), + migrations.AddField( + model_name="invite", + name="is_revoked", + field=models.BooleanField(default=False, verbose_name="Отозвано"), + ), + migrations.AddField( + model_name="invite", + name="resolved_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата обработки" + ), + ), + migrations.RunPython( + populate_invitation_lifecycle, + migrations.RunPython.noop, + ), + migrations.AddIndex( + model_name="invite", + index=models.Index( + fields=["user", "is_accepted", "is_revoked", "datetime_created"], + name="invite_user_state_idx", + ), + ), + migrations.AddIndex( + model_name="invite", + index=models.Index( + fields=["project", "is_accepted", "is_revoked", "datetime_created"], + name="invite_project_state_idx", + ), + ), + migrations.AddConstraint( + model_name="invite", + constraint=models.UniqueConstraint( + condition=models.Q(("is_accepted__isnull", True), ("is_revoked", False)), + fields=("project", "user"), + name="uniq_pending_project_invite", + ), + ), + migrations.AddConstraint( + model_name="invite", + constraint=models.CheckConstraint( + check=models.Q( + ("is_revoked", False), ("is_accepted__isnull", True), _connector="OR" + ), + name="invite_revoked_unaccepted", + ), + ), + ] diff --git a/invites/models.py b/invites/models.py index 42118726..9c4c4717 100644 --- a/invites/models.py +++ b/invites/models.py @@ -16,12 +16,28 @@ class Invite(models.Model): user: A ForeignKey referring to the user, who got the invite motivational_letter: A TextField where the project can tell the user why they need him is_accepted: A BooleanField indicating whether the receiver accepted the invite or declined it + invited_by: Пользователь, который создал приглашение + is_revoked: Признак исторически сохраненного отзыва + resolved_at: Дата принятия, отклонения или отзыва datetime_created: A DateTimeField indicating date of creation datetime_updated: A DateTimeField indicating date of update """ + STATUS_PENDING = "pending" + STATUS_ACCEPTED = "accepted" + STATUS_DECLINED = "declined" + STATUS_REVOKED = "revoked" + project = models.ForeignKey(Project, on_delete=models.CASCADE) user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + invited_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sent_project_invites", + verbose_name="Кем приглашен", + ) motivational_letter = models.TextField( max_length=4096, blank=True, null=True, default=None @@ -35,6 +51,12 @@ class Invite(models.Model): verbose_name="Специализация", ) is_accepted = models.BooleanField(blank=False, null=True, default=None) + is_revoked = models.BooleanField(default=False, verbose_name="Отозвано") + resolved_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата обработки", + ) datetime_created = models.DateTimeField( verbose_name="Дата создания", null=False, auto_now_add=True @@ -46,11 +68,52 @@ class Invite(models.Model): objects = InviteManager() def __str__(self) -> str: - return ( - f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' - ) + return f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' + + @property + def status(self) -> str: + """Возвращает lifecycle-статус без изменения legacy `is_accepted`.""" + if self.is_revoked: + return self.STATUS_REVOKED + if self.is_accepted is True: + return self.STATUS_ACCEPTED + if self.is_accepted is False: + return self.STATUS_DECLINED + return self.STATUS_PENDING + + @property + def is_pending(self) -> bool: + return self.is_accepted is None and not self.is_revoked class Meta(TypedModelMeta): verbose_name = "Приглашение" verbose_name_plural = "Приглашения" ordering = ["-datetime_created"] + constraints = [ + # Завершенные приглашения остаются в истории и не мешают + # повторному приглашению того же пользователя. + models.UniqueConstraint( + fields=["project", "user"], + condition=models.Q(is_accepted__isnull=True, is_revoked=False), + name="uniq_pending_project_invite", + ), + models.CheckConstraint( + check=models.Q(is_revoked=False) | models.Q(is_accepted__isnull=True), + name="invite_revoked_unaccepted", + ), + ] + indexes = [ + models.Index( + fields=["user", "is_accepted", "is_revoked", "datetime_created"], + name="invite_user_state_idx", + ), + models.Index( + fields=[ + "project", + "is_accepted", + "is_revoked", + "datetime_created", + ], + name="invite_project_state_idx", + ), + ] diff --git a/invites/serializers.py b/invites/serializers.py index 42a032e2..194bb57d 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -49,7 +49,10 @@ def validate(self, attrs): ) if Invite.objects.filter( - project=project, user=user, is_accepted__isnull=True + project=project, + user=user, + is_accepted__isnull=True, + is_revoked=False, ).exists(): raise serializers.ValidationError( {"user": "У пользователя уже есть активное приглашение в этот проект."} diff --git a/invites/tests/test_project_workspace_invitation_api.py b/invites/tests/test_project_workspace_invitation_api.py new file mode 100644 index 00000000..c967d968 --- /dev/null +++ b/invites/tests/test_project_workspace_invitation_api.py @@ -0,0 +1,502 @@ +from unittest.mock import patch + +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_invite, + create_project, + create_user, + link_project_to_program, +) +from projects.models import Collaborator + + +class ProjectWorkspaceInvitationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="workspace-invite-leader") + self.collaborator = create_user(prefix="workspace-invite-collaborator") + self.recipient = create_user(prefix="workspace-invite-recipient") + self.outsider = create_user(prefix="workspace-invite-outsider") + self.staff = create_user(prefix="workspace-invite-staff", is_staff=True) + self.project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + add_collaborator(project=self.project, user=self.collaborator) + + def authenticate(self, user): + self.client.force_authenticate(user=user) + + def invitations_url(self, project=None): + project = project or self.project + return f"/projects/{project.pk}/workspace/invitations/" + + def revoke_url(self, invitation, project=None): + project = project or self.project + return f"{self.invitations_url(project)}{invitation.pk}/revoke/" + + @staticmethod + def incoming_url(): + return "/projects/workspace/invitations/incoming/" + + @staticmethod + def accept_url(invitation): + return f"/projects/workspace/invitations/{invitation.pk}/accept/" + + @staticmethod + def decline_url(invitation): + return f"/projects/workspace/invitations/{invitation.pk}/decline/" + + def create_workspace_invitation(self, *, recipient=None, **overrides): + recipient = recipient or self.recipient + defaults = { + "project": self.project, + "user": recipient, + "invited_by": self.leader, + "role": "Разработчик", + "specialization": "Backend", + "motivational_letter": "Присоединяйтесь к проекту", + } + defaults.update(overrides) + return Invite.objects.create(**defaults) + + def test_workspace_invitation_endpoints_require_authentication(self): + invitation = self.create_workspace_invitation() + + responses = ( + self.client.get(self.invitations_url()), + self.client.post( + self.invitations_url(), + {"recipient_id": self.outsider.pk}, + format="json", + ), + self.client.get(self.incoming_url()), + self.client.post(self.accept_url(invitation), {}, format="json"), + self.client.post(self.decline_url(invitation), {}, format="json"), + self.client.post(self.revoke_url(invitation), {}, format="json"), + ) + + self.assertTrue( + all( + response.status_code == status.HTTP_401_UNAUTHORIZED + for response in responses + ) + ) + + def test_leader_creates_pending_invitation_with_safe_response(self): + self.authenticate(self.leader) + + response = self.client.post( + self.invitations_url(), + { + "recipient_id": self.recipient.pk, + "role": "Разработчик", + "specialization": "Backend", + "message": "Присоединяйтесь к проекту", + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + invitation = Invite.objects.get(pk=response.data["id"]) + self.assertEqual(invitation.project, self.project) + self.assertEqual(invitation.user, self.recipient) + self.assertEqual(invitation.invited_by, self.leader) + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertEqual(response.data["status"], Invite.STATUS_PENDING) + self.assertEqual(response.data["project"]["id"], self.project.pk) + self.assertEqual(response.data["sender"]["id"], self.leader.pk) + self.assertEqual(response.data["recipient"]["id"], self.recipient.pk) + self.assertNotIn("email", response.data["recipient"]) + self.assertEqual(response.data["message"], "Присоединяйтесь к проекту") + self.assertIsNone(response.data["processed_at"]) + + def test_only_leader_or_staff_can_create_invitation(self): + for actor, expected_status in ( + (self.collaborator, status.HTTP_403_FORBIDDEN), + (self.outsider, status.HTTP_404_NOT_FOUND), + ): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(response.status_code, expected_status) + + self.authenticate(self.staff) + staff_response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(staff_response.status_code, status.HTTP_201_CREATED) + + def test_create_rejects_leader_collaborator_duplicate_and_invalid_user(self): + self.authenticate(self.leader) + invalid_targets = ( + (self.leader.pk, status.HTTP_400_BAD_REQUEST), + (self.collaborator.pk, status.HTTP_400_BAD_REQUEST), + (999999, status.HTTP_400_BAD_REQUEST), + ) + for recipient_id, expected_status in invalid_targets: + with self.subTest(recipient_id=recipient_id): + response = self.client.post( + self.invitations_url(), + {"recipient_id": recipient_id}, + format="json", + ) + self.assertEqual(response.status_code, expected_status) + self.assertIn("recipient_id", response.data) + + self.create_workspace_invitation() + duplicate_response = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(duplicate_response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 1, + ) + + def test_create_rejects_inactive_user_and_read_only_identifiers(self): + inactive = create_user(prefix="workspace-invite-inactive") + inactive.is_active = False + inactive.save(update_fields=["is_active"]) + self.authenticate(self.leader) + + inactive_response = self.client.post( + self.invitations_url(), + {"recipient_id": inactive.pk}, + format="json", + ) + self.assertEqual(inactive_response.status_code, status.HTTP_400_BAD_REQUEST) + + for forbidden_field in ("project", "user", "recipient", "sender", "status"): + with self.subTest(field=forbidden_field): + response = self.client.post( + self.invitations_url(), + { + "recipient_id": self.recipient.pk, + forbidden_field: self.outsider.pk, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn(forbidden_field, response.data) + + self.assertFalse( + Invite.objects.filter(project=self.project, user=self.recipient).exists() + ) + + def test_legacy_program_project_requires_recipient_membership(self): + program = link_project_to_program(project=self.project) + self.authenticate(self.leader) + + rejected = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(rejected.status_code, status.HTTP_400_BAD_REQUEST) + + add_user_to_program(user=self.recipient, program=program) + accepted = self.client.post( + self.invitations_url(), + {"recipient_id": self.recipient.pk}, + format="json", + ) + self.assertEqual(accepted.status_code, status.HTTP_201_CREATED) + + def test_leader_lists_only_project_invitation_history(self): + own_pending = self.create_workspace_invitation() + own_declined = self.create_workspace_invitation( + recipient=self.outsider, + is_accepted=False, + ) + other_project = create_project(leader=self.leader) + create_invite(project=other_project) + self.authenticate(self.leader) + + response = self.client.get(self.invitations_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {own_pending.pk, own_declined.pk}, + ) + self.assertEqual( + {item["project"]["id"] for item in response.data}, + {self.project.pk}, + ) + + def test_collaborator_and_outsider_cannot_list_project_invitations(self): + self.create_workspace_invitation() + for actor, expected_status in ( + (self.collaborator, status.HTTP_403_FORBIDDEN), + (self.outsider, status.HTTP_404_NOT_FOUND), + ): + with self.subTest(actor=actor.pk): + self.authenticate(actor) + response = self.client.get(self.invitations_url()) + self.assertEqual(response.status_code, expected_status) + + def test_recipient_lists_only_own_incoming_invitation_history(self): + own = self.create_workspace_invitation() + own_declined = self.create_workspace_invitation( + project=create_project(), + user=self.recipient, + invited_by=None, + is_accepted=False, + ) + self.create_workspace_invitation(recipient=self.outsider) + self.authenticate(self.recipient) + + response = self.client.get(self.incoming_url()) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], [own.pk, own_declined.pk] + ) + self.assertTrue( + all(item["recipient"]["id"] == self.recipient.pk for item in response.data) + ) + + def test_recipient_accepts_invitation_atomically_and_only_once(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + response = self.client.post(self.accept_url(invitation), {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + collaborator = Collaborator.objects.get( + project=self.project, + user=self.recipient, + ) + self.assertEqual(invitation.status, Invite.STATUS_ACCEPTED) + self.assertIsNotNone(invitation.resolved_at) + self.assertEqual(collaborator.role, invitation.role) + self.assertEqual(collaborator.specialization, invitation.specialization) + self.assertEqual(response.data["status"], Invite.STATUS_ACCEPTED) + self.assertIsNotNone(response.data["processed_at"]) + + repeated = self.client.post(self.accept_url(invitation), {}, format="json") + self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) + decline_after_accept = self.client.post( + self.decline_url(invitation), {}, format="json" + ) + self.assertEqual(decline_after_accept.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + Collaborator.objects.filter( + project=self.project, + user=self.recipient, + ).count(), + 1, + ) + + def test_failed_accept_rolls_back_invitation_and_collaborator(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + with patch( + "invites.workspace_services.Collaborator.objects.create", + side_effect=IntegrityError("forced failure"), + ): + with self.assertRaises(IntegrityError): + self.client.post(self.accept_url(invitation), {}, format="json") + + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertIsNone(invitation.resolved_at) + self.assertFalse( + Collaborator.objects.filter( + project=self.project, + user=self.recipient, + ).exists() + ) + + def test_other_user_cannot_accept_or_decline_invitation(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.outsider) + + self.assertEqual( + self.client.post(self.accept_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.assertEqual( + self.client.post(self.decline_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + + def test_recipient_declines_once_and_declined_or_revoked_cannot_be_accepted(self): + declined = self.create_workspace_invitation() + revoked = self.create_workspace_invitation(recipient=self.outsider) + + self.authenticate(self.recipient) + decline_response = self.client.post(self.decline_url(declined), {}, format="json") + self.assertEqual(decline_response.status_code, status.HTTP_200_OK) + self.assertEqual(decline_response.data["status"], Invite.STATUS_DECLINED) + repeated_decline = self.client.post(self.decline_url(declined), {}, format="json") + self.assertEqual(repeated_decline.status_code, status.HTTP_409_CONFLICT) + declined_accept = self.client.post(self.accept_url(declined), {}, format="json") + self.assertEqual(declined_accept.status_code, status.HTTP_409_CONFLICT) + + self.authenticate(self.leader) + revoke_response = self.client.post(self.revoke_url(revoked), {}, format="json") + self.assertEqual(revoke_response.status_code, status.HTTP_200_OK) + self.authenticate(self.outsider) + revoked_accept = self.client.post(self.accept_url(revoked), {}, format="json") + self.assertEqual(revoked_accept.status_code, status.HTTP_409_CONFLICT) + + def test_leader_revokes_pending_invitation_without_deleting_history(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.leader) + + response = self.client.post(self.revoke_url(invitation), {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_REVOKED) + self.assertIsNotNone(invitation.resolved_at) + self.assertTrue(Invite.objects.filter(pk=invitation.pk).exists()) + repeated = self.client.post(self.revoke_url(invitation), {}, format="json") + self.assertEqual(repeated.status_code, status.HTTP_409_CONFLICT) + + def test_collaborator_outsider_and_wrong_project_cannot_revoke(self): + invitation = self.create_workspace_invitation() + other_project = create_project(leader=self.leader) + + self.authenticate(self.collaborator) + self.assertEqual( + self.client.post(self.revoke_url(invitation), {}, format="json").status_code, + status.HTTP_403_FORBIDDEN, + ) + self.authenticate(self.outsider) + self.assertEqual( + self.client.post(self.revoke_url(invitation), {}, format="json").status_code, + status.HTTP_404_NOT_FOUND, + ) + self.authenticate(self.leader) + self.assertEqual( + self.client.post( + self.revoke_url(invitation, other_project), + {}, + format="json", + ).status_code, + status.HTTP_404_NOT_FOUND, + ) + + def test_staff_can_revoke_but_processed_invitation_cannot_be_revoked(self): + pending = self.create_workspace_invitation() + processed = self.create_workspace_invitation( + recipient=self.outsider, + is_accepted=False, + ) + self.authenticate(self.staff) + + self.assertEqual( + self.client.post(self.revoke_url(pending), {}, format="json").status_code, + status.HTTP_200_OK, + ) + self.assertEqual( + self.client.post(self.revoke_url(processed), {}, format="json").status_code, + status.HTTP_409_CONFLICT, + ) + + def test_action_payload_cannot_override_project_recipient_or_status(self): + invitation = self.create_workspace_invitation() + self.authenticate(self.recipient) + + response = self.client.post( + self.accept_url(invitation), + {"project": 999, "recipient_id": self.outsider.pk, "status": "accepted"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + invitation.refresh_from_db() + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertFalse( + Collaborator.objects.filter( + project=self.project, user=self.recipient + ).exists() + ) + + def test_database_rejects_two_active_invitations(self): + self.create_workspace_invitation() + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self.create_workspace_invitation() + + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 1, + ) + + def test_completed_invitation_allows_a_new_pending_invitation(self): + self.create_workspace_invitation(is_accepted=False) + + invitation = self.create_workspace_invitation() + + self.assertEqual(invitation.status, Invite.STATUS_PENDING) + self.assertEqual( + Invite.objects.filter(project=self.project, user=self.recipient).count(), + 2, + ) + + def test_project_and_incoming_lists_have_bounded_queries(self): + self.create_workspace_invitation() + for index in range(5): + recipient = create_user(prefix=f"workspace-list-recipient-{index}") + self.create_workspace_invitation(recipient=recipient) + self.authenticate(self.leader) + + with CaptureQueriesContext(connection) as project_queries: + project_response = self.client.get(self.invitations_url()) + self.authenticate(self.recipient) + with CaptureQueriesContext(connection) as incoming_queries: + incoming_response = self.client.get(self.incoming_url()) + + self.assertEqual(project_response.status_code, status.HTTP_200_OK) + self.assertEqual(incoming_response.status_code, status.HTTP_200_OK) + self.assertLessEqual(len(project_queries), 4) + self.assertLessEqual(len(incoming_queries), 2) + + def test_legacy_invite_and_workspace_content_endpoints_remain_available(self): + self.authenticate(self.leader) + + legacy_response = self.client.post( + "/invites/", + { + "project": self.project.pk, + "user": self.recipient.pk, + "role": "Legacy role", + }, + format="json", + ) + workspace_response = self.client.get(f"/projects/{self.project.pk}/workspace/") + goals_response = self.client.get(f"/projects/{self.project.pk}/workspace/goals/") + achievements_response = self.client.get( + f"/projects/{self.project.pk}/workspace/achievements/" + ) + + self.assertEqual(legacy_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(workspace_response.status_code, status.HTTP_200_OK) + self.assertEqual(goals_response.status_code, status.HTTP_200_OK) + self.assertEqual(achievements_response.status_code, status.HTTP_200_OK) diff --git a/invites/views.py b/invites/views.py index aeb5edfd..e158ce83 100644 --- a/invites/views.py +++ b/invites/views.py @@ -7,7 +7,11 @@ from invites.permissions import InviteDecisionPermission, InviteDetailPermission from invites.querysets import get_visible_invites_queryset from invites.serializers import InviteDetailSerializer, InviteListSerializer -from projects.models import Collaborator +from invites.workspace_services import ( + ProjectInvitationServiceError, + accept_project_invitation, + decline_project_invitation, +) class InviteList(generics.ListCreateAPIView): @@ -18,7 +22,8 @@ class InviteList(generics.ListCreateAPIView): def get_queryset(self): return get_visible_invites_queryset(self.request.user).filter( - is_accepted__isnull=True + is_accepted__isnull=True, + is_revoked=False, ) def create(self, request, *args, **kwargs): @@ -27,7 +32,7 @@ def create(self, request, *args, **kwargs): if serializer.validated_data["project"].leader != request.user: # additional check that the user is the invite's project's leader return Response(status=status.HTTP_403_FORBIDDEN) - instance = serializer.save() + instance = serializer.save(invited_by=request.user) headers = self.get_success_headers(serializer.data) # using detailed serializer so that it'll pass User and Project objects detailed @@ -51,27 +56,16 @@ class InviteAccept(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() # type: Invite - if invite.is_accepted is not None: - return Response( - {"detail": "Invite has already been processed."}, - status=status.HTTP_409_CONFLICT, + try: + accept_project_invitation( + invitation_id=invite.pk, + actor=request.user, ) - # add user to project collaborators - collaborator, created = Collaborator.objects.get_or_create( - user=invite.user, - project=invite.project, - defaults={ - "role": invite.role, - "specialization": invite.specialization, - }, - ) - if not created: + except ProjectInvitationServiceError as exc: return Response( - {"detail": "User is already a collaborator of this project."}, + {"detail": exc.detail}, status=status.HTTP_409_CONFLICT, ) - invite.is_accepted = True - invite.save() return Response(status=status.HTTP_200_OK) @@ -82,11 +76,14 @@ class InviteDecline(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() - if invite.is_accepted is not None: + try: + decline_project_invitation( + invitation_id=invite.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: return Response( - {"detail": "Invite has already been processed."}, + {"detail": exc.detail}, status=status.HTTP_409_CONFLICT, ) - invite.is_accepted = False - invite.save() return Response(status=status.HTTP_200_OK) diff --git a/invites/workspace_serializers.py b/invites/workspace_serializers.py new file mode 100644 index 00000000..20ce857f --- /dev/null +++ b/invites/workspace_serializers.py @@ -0,0 +1,106 @@ +from rest_framework import serializers + +from invites.models import Invite +from projects.models import Project +from users.models import CustomUser + + +class ProjectInvitationCreateSerializer(serializers.Serializer): + """Принимает пользователя и необязательные данные будущего Collaborator.""" + + recipient_id = serializers.IntegerField(min_value=1) + role = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=128, + ) + specialization = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=100, + ) + message = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=4096, + ) + + def validate(self, attrs): + unsupported = set(self.initial_data).difference(self.fields) + if unsupported: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать при создании приглашения." + for field in sorted(unsupported) + } + ) + return attrs + + +class ProjectInvitationActionSerializer(serializers.Serializer): + """Запрещает подмену Project, получателя или статуса в action payload.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать для данного действия." + for field in sorted(self.initial_data) + } + ) + return attrs + + +class ProjectInvitationUserSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ("id", "first_name", "last_name", "avatar") + read_only_fields = fields + + +class ProjectInvitationProjectSerializer(serializers.ModelSerializer): + class Meta: + model = Project + fields = ("id", "name", "draft", "is_public") + read_only_fields = fields + + +class ProjectInvitationSerializer(serializers.ModelSerializer): + """Не раскрывает email и приватные поля профилей участников Project.""" + + project = ProjectInvitationProjectSerializer(read_only=True) + sender = serializers.SerializerMethodField() + recipient = ProjectInvitationUserSerializer(source="user", read_only=True) + status = serializers.CharField(read_only=True) + message = serializers.CharField( + source="motivational_letter", + read_only=True, + allow_null=True, + ) + processed_at = serializers.DateTimeField(source="resolved_at", read_only=True) + created_at = serializers.DateTimeField(source="datetime_created", read_only=True) + updated_at = serializers.DateTimeField(source="datetime_updated", read_only=True) + + class Meta: + model = Invite + fields = ( + "id", + "project", + "sender", + "recipient", + "status", + "role", + "specialization", + "message", + "created_at", + "processed_at", + "updated_at", + ) + read_only_fields = fields + + def get_sender(self, invitation: Invite): + sender = invitation.invited_by or invitation.project.leader + return ProjectInvitationUserSerializer(sender).data diff --git a/invites/workspace_services.py b/invites/workspace_services.py new file mode 100644 index 00000000..dc3b9ce3 --- /dev/null +++ b/invites/workspace_services.py @@ -0,0 +1,189 @@ +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from invites.models import Invite +from partner_programs.models import PartnerProgramUserProfile +from projects.models import Collaborator, Project + +User = get_user_model() + + +class ProjectInvitationServiceError(Exception): + """Базовая контролируемая ошибка lifecycle приглашения в Project.""" + + code = "project_invitation_error" + default_detail = "Операция с приглашением недоступна." + default_field = "non_field_errors" + + def __init__(self, detail=None, *, field=None): + self.detail = detail or self.default_detail + self.field = field or self.default_field + super().__init__(self.detail) + + +class ProjectInvitationPermissionError(ProjectInvitationServiceError): + code = "project_invitation_permission_denied" + default_detail = "У вас нет прав для управления приглашениями проекта." + default_field = "project" + + +class ProjectInvitationNotPendingError(ProjectInvitationServiceError): + code = "project_invitation_not_pending" + default_detail = "Приглашение уже обработано и не может быть изменено." + default_field = "status" + + +class ProjectInvitationTargetInvalidError(ProjectInvitationServiceError): + code = "project_invitation_target_invalid" + default_detail = "Пользователя нельзя пригласить в этот проект." + default_field = "recipient_id" + + +class ProjectInvitationDuplicateError(ProjectInvitationServiceError): + code = "project_invitation_duplicate" + default_detail = "Активное приглашение для этого пользователя уже существует." + default_field = "recipient_id" + + +class ProjectInvitationNotOwnedError(ProjectInvitationServiceError): + code = "project_invitation_not_owned" + default_detail = "Это приглашение предназначено другому пользователю." + default_field = "invitation" + + +def can_manage_project_invitations(user, project: Project) -> bool: + """Разрешает управление только лидеру Project и административным ролям.""" + return bool( + user + and user.is_authenticated + and (user.is_staff or user.is_superuser or project.leader_id == user.pk) + ) + + +def _require_pending(invitation: Invite) -> None: + if not invitation.is_pending: + raise ProjectInvitationNotPendingError() + + +def _require_eligible_recipient(*, project: Project, recipient: User) -> None: + if project.leader_id == recipient.pk: + raise ProjectInvitationTargetInvalidError("Руководитель уже состоит в проекте.") + if Collaborator.objects.filter(project=project, user=recipient).exists(): + raise ProjectInvitationTargetInvalidError( + "Пользователь уже является участником проекта." + ) + + # Legacy Project может быть напрямую связан с одной PartnerProgram. + # Повторяем invariant Collaborator.clean(), иначе принятие заведомо упадет. + program_link = project.program_links.only("partner_program_id").first() + if ( + program_link + and not PartnerProgramUserProfile.objects.filter( + partner_program_id=program_link.partner_program_id, + user=recipient, + ).exists() + ): + raise ProjectInvitationTargetInvalidError( + "Пользователь не является участником программы проекта." + ) + + +def _lock_invitation_graph(invitation_id: int) -> tuple[Project, Invite]: + """Блокирует Project и Invite в едином порядке для всех переходов статуса.""" + reference = Invite.objects.only("project_id").get(pk=invitation_id) + project = Project.objects.select_for_update().get(pk=reference.project_id) + invitation = Invite.objects.select_for_update().get(pk=invitation_id) + return project, invitation + + +def create_project_invitation( + *, + project_id: int, + actor: User, + recipient: User, + role: str | None = None, + specialization: str | None = None, + motivational_letter: str | None = None, +) -> Invite: + """Создает единственное pending-приглашение под блокировкой Project.""" + with transaction.atomic(): + project = Project.objects.select_for_update().get(pk=project_id) + if not can_manage_project_invitations(actor, project): + raise ProjectInvitationPermissionError() + _require_eligible_recipient(project=project, recipient=recipient) + + pending = Invite.objects.select_for_update().filter( + project=project, + user=recipient, + is_accepted__isnull=True, + is_revoked=False, + ) + if pending.exists(): + raise ProjectInvitationDuplicateError() + + try: + # Savepoint сохраняет внешнюю транзакцию пригодной для проверки + # partial unique constraint после конкурентного INSERT. + with transaction.atomic(): + invitation = Invite.objects.create( + project=project, + user=recipient, + invited_by=actor, + role=role, + specialization=specialization, + motivational_letter=motivational_letter, + ) + except IntegrityError as exc: + if pending.exists(): + raise ProjectInvitationDuplicateError() from exc + raise + return invitation + + +def accept_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Атомарно принимает приглашение и создает Collaborator один раз.""" + with transaction.atomic(): + project, invitation = _lock_invitation_graph(invitation_id) + if invitation.user_id != actor.pk: + raise ProjectInvitationNotOwnedError() + _require_pending(invitation) + recipient = User.objects.get(pk=invitation.user_id) + _require_eligible_recipient(project=project, recipient=recipient) + + Collaborator.objects.create( + project=project, + user=recipient, + role=invitation.role, + specialization=invitation.specialization, + ) + invitation.is_accepted = True + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + return invitation + + +def decline_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Завершает pending-приглашение решением его получателя.""" + with transaction.atomic(): + _project, invitation = _lock_invitation_graph(invitation_id) + if invitation.user_id != actor.pk: + raise ProjectInvitationNotOwnedError() + _require_pending(invitation) + invitation.is_accepted = False + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_accepted", "resolved_at", "datetime_updated"]) + return invitation + + +def revoke_project_invitation(*, invitation_id: int, actor: User) -> Invite: + """Сохраняет отзыв в истории вместо физического удаления Invite.""" + with transaction.atomic(): + project, invitation = _lock_invitation_graph(invitation_id) + if not can_manage_project_invitations(actor, project): + raise ProjectInvitationPermissionError() + _require_pending(invitation) + invitation.is_revoked = True + invitation.resolved_at = timezone.now() + invitation.save(update_fields=["is_revoked", "resolved_at", "datetime_updated"]) + return invitation diff --git a/invites/workspace_views.py b/invites/workspace_views.py new file mode 100644 index 00000000..1a4e5785 --- /dev/null +++ b/invites/workspace_views.py @@ -0,0 +1,227 @@ +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 ( + APIException, + NotFound, + PermissionDenied, + ValidationError, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from invites.models import Invite +from invites.workspace_serializers import ( + ProjectInvitationActionSerializer, + ProjectInvitationCreateSerializer, + ProjectInvitationSerializer, +) +from invites.workspace_services import ( + ProjectInvitationDuplicateError, + ProjectInvitationNotOwnedError, + ProjectInvitationNotPendingError, + ProjectInvitationPermissionError, + ProjectInvitationServiceError, + ProjectInvitationTargetInvalidError, + accept_project_invitation, + can_manage_project_invitations, + create_project_invitation, + decline_project_invitation, + revoke_project_invitation, +) +from projects.models import Project +from projects.workspace_selectors import filter_workspace_visible_projects + +User = get_user_model() + + +class ProjectInvitationConflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = "Приглашение уже обработано." + default_code = "project_invitation_conflict" + + +def _invitation_queryset(): + return Invite.objects.select_related( + "project", + "project__leader", + "user", + "invited_by", + ) + + +def _get_visible_project(*, project_id: int, user) -> Project: + projects = Project.objects.select_related("leader") + projects = filter_workspace_visible_projects(projects, user=user) + return get_object_or_404(projects, pk=project_id) + + +def _require_manager(user, project: Project) -> None: + if not can_manage_project_invitations(user, project): + raise PermissionDenied( + ProjectInvitationPermissionError.default_detail, + code=ProjectInvitationPermissionError.code, + ) + + +def _raise_domain_error(exc: ProjectInvitationServiceError): + if isinstance(exc, ProjectInvitationPermissionError): + raise PermissionDenied(exc.detail, code=exc.code) from exc + if isinstance(exc, ProjectInvitationNotOwnedError): + raise NotFound("Project invitation not found.") from exc + if isinstance( + exc, (ProjectInvitationDuplicateError, ProjectInvitationNotPendingError) + ): + raise ProjectInvitationConflict( + {exc.field: [exc.detail]}, + code=exc.code, + ) from exc + raise ValidationError({exc.field: [exc.detail]}, code=exc.code) from exc + + +def _validate_empty_action_payload(data) -> None: + serializer = ProjectInvitationActionSerializer(data=data) + serializer.is_valid(raise_exception=True) + + +class ProjectInvitationListCreateView(APIView): + """Возвращает историю приглашений Project и создает pending-запись.""" + + permission_classes = [IsAuthenticated] + + def get(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + invitations = ( + _invitation_queryset() + .filter(project=project) + .order_by( + "-datetime_created", + "-id", + ) + ) + return Response(ProjectInvitationSerializer(invitations, many=True).data) + + def post(self, request, project_id): + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + serializer = ProjectInvitationCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + recipient = User.objects.filter( + pk=serializer.validated_data["recipient_id"], + is_active=True, + ).first() + if recipient is None: + _raise_domain_error(ProjectInvitationTargetInvalidError()) + + try: + invitation = create_project_invitation( + project_id=project.pk, + actor=request.user, + recipient=recipient, + role=serializer.validated_data.get("role"), + specialization=serializer.validated_data.get("specialization"), + motivational_letter=serializer.validated_data.get("message"), + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response( + ProjectInvitationSerializer(invitation).data, + status=status.HTTP_201_CREATED, + ) + + +class IncomingProjectInvitationListView(APIView): + """Возвращает только историю приглашений текущего пользователя.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + invitations = ( + _invitation_queryset() + .filter(user=request.user) + .annotate( + pending_order=Case( + When( + is_accepted__isnull=True, + is_revoked=False, + then=Value(0), + ), + default=Value(1), + output_field=IntegerField(), + ) + ) + .order_by("pending_order", "-datetime_created", "-id") + ) + return Response(ProjectInvitationSerializer(invitations, many=True).data) + + +class ProjectInvitationAcceptView(APIView): + """Принимает pending-приглашение только его получателем.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, invitation_id): + _validate_empty_action_payload(request.data) + invitation = get_object_or_404( + _invitation_queryset().filter(user=request.user), + pk=invitation_id, + ) + try: + invitation = accept_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) + + +class ProjectInvitationDeclineView(APIView): + """Отклоняет pending-приглашение только его получателем.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, invitation_id): + _validate_empty_action_payload(request.data) + invitation = get_object_or_404( + _invitation_queryset().filter(user=request.user), + pk=invitation_id, + ) + try: + invitation = decline_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) + + +class ProjectInvitationRevokeView(APIView): + """Отзывает pending-приглашение через project-scoped URL без удаления истории.""" + + permission_classes = [IsAuthenticated] + + def post(self, request, project_id, invitation_id): + _validate_empty_action_payload(request.data) + project = _get_visible_project(project_id=project_id, user=request.user) + _require_manager(request.user, project) + invitation = get_object_or_404( + _invitation_queryset().filter(project=project), + pk=invitation_id, + ) + try: + invitation = revoke_project_invitation( + invitation_id=invitation.pk, + actor=request.user, + ) + except ProjectInvitationServiceError as exc: + _raise_domain_error(exc) + invitation = _invitation_queryset().get(pk=invitation.pk) + return Response(ProjectInvitationSerializer(invitation).data) diff --git a/projects/urls.py b/projects/urls.py index c9d2f4f0..995b7006 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -1,5 +1,12 @@ from django.urls import path +from invites.workspace_views import ( + IncomingProjectInvitationListView, + ProjectInvitationAcceptView, + ProjectInvitationDeclineView, + ProjectInvitationListCreateView, + ProjectInvitationRevokeView, +) from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList from partner_programs.views import PartnerProgramFieldValueBulkUpdateView from projects.views import ( @@ -77,11 +84,36 @@ ProjectWorkspaceCreateView.as_view(), name="workspace-create", ), + path( + "workspace/invitations/incoming/", + IncomingProjectInvitationListView.as_view(), + name="workspace-invitations-incoming", + ), + path( + "workspace/invitations//accept/", + ProjectInvitationAcceptView.as_view(), + name="workspace-invitation-accept", + ), + path( + "workspace/invitations//decline/", + ProjectInvitationDeclineView.as_view(), + name="workspace-invitation-decline", + ), path( "/workspace/", ProjectWorkspaceDetailView.as_view(), name="workspace-detail", ), + path( + "/workspace/invitations/", + ProjectInvitationListCreateView.as_view(), + name="workspace-invitations", + ), + path( + "/workspace/invitations//revoke/", + ProjectInvitationRevokeView.as_view(), + name="workspace-invitation-revoke", + ), path( "/workspace/goals/", ProjectWorkspaceGoalListView.as_view(),