From 11547cab5af868125c150e53c8f4061d94b1554b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 29 Sep 2026 14:54:09 +0300 Subject: [PATCH] fix: serialize legacy invite transitions and enforce pending uniqueness --- docs/invite-integrity-api.md | 86 ++++++++ .../0003_legacy_pending_invite_constraint.py | 34 +++ invites/models.py | 11 +- invites/serializers.py | 62 ++---- invites/tests/test_invite_api.py | 18 +- invites/tests/test_invite_integrity.py | 207 ++++++++++++++++++ .../tests/test_pending_invite_migration.py | 48 ++++ invites/views.py | 53 ++--- projects/team_service.py | 140 +++++++++++- 9 files changed, 563 insertions(+), 96 deletions(-) create mode 100644 docs/invite-integrity-api.md create mode 100644 invites/migrations/0003_legacy_pending_invite_constraint.py create mode 100644 invites/tests/test_invite_integrity.py create mode 100644 invites/tests/test_pending_invite_migration.py diff --git a/docs/invite-integrity-api.md b/docs/invite-integrity-api.md new file mode 100644 index 00000000..cf2689de --- /dev/null +++ b/docs/invite-integrity-api.md @@ -0,0 +1,86 @@ +# Целостность legacy Invite (PR B) + +База: PR A, `c44bcf089c41c6e8c657a70390e98c46bc2510e7`. +Service: `projects/team_service.py`; models/views/serializers остаются HTTP/ORM-адаптерами. + +## Контракт и механизм + +Пути и успешные ответы Angular сохранены: POST create → 201, accept/decline → 200, +PATCH/PUT → 200, DELETE pending → 204 с физическим удалением записи. +Все lifecycle writers используют atomic и порядок `Project → Invite → membership`. +Actor проверяется повторно после Project lock. Accept создаёт Collaborator и +завершает Invite в одной транзакции; ошибка любого шага откатывает оба изменения. +Для существующего PUP берётся row lock до создания Collaborator. + +До: проверки create вне транзакции; accept не atomic; PATCH мог записать stale +is_accepted; revoke не был согласован с accept. После: service перечитывает Invite +под lock, проверяет pending и записывает только нужные `update_fields`. + +| Конфликт | Новый ответ | +| --- | --- | +| Второе pending приглашение | 409 `duplicate_pending_invite` | +| Accept/decline/edit/revoke обработанного Invite | 409 `invite_already_processed` | +| Membership программы отсутствует при create/accept | 409 `not_program_member` | +| Пользователь уже collaborator / leader | 409 `already_project_member` / `already_project_leader` | +| Actor не может управлять / принимать Invite | 403 `not_project_team_manager` / `not_invite_recipient` | +| Запись удалена конкурентным revoke | 404, без Collaborator и без восстановления Invite | + +Форма ошибок: `{"code": "stable_code", "detail": "сообщение"}`. Malformed payload +остаётся 400 DRF. Business conflicts create ранее были 400 с полем `user`, теперь 409 +с явным code. Новый Angular mapping входит в PR D. Permission публичного legacy API +сохраняется: staff не получает нового права управления Invite. + +## Выборочный перенос PROD + +Из `master e54545e…`, `invites/workspace_services.py` использованы механизмы atomic, +Project-before-Invite locks, current-membership check и savepoint для unique INSERT. +PROD целиком не переносился. В DEV нет notifications domain; новых внешних side effects +не добавлено. `invited_by`, `is_revoked`, `resolved_at` и nextgen не переносились. +В DEV revoke — DELETE, поэтому active pending = `is_accepted IS NULL`. + +## Data audit и migration + +Source audit: на DEV есть только migrations 0001/0002 Invite и нет pending constraint. +PROD 0003 содержит другие поля и автоматически отзывает старые дубли; такая стратегия +не переносится, поскольку ТЗ запрещает самостоятельно выбирать победителя. + +Новая `0003_legacy_pending_invite_constraint` проверяет дубли и добавляет partial unique +`uniq_legacy_pending_invite(project_id,user_id) WHERE is_accepted IS NULL`. +Откат удаляет constraint, сохраняя все данные. Чтение production DB не выполнялось. + +Read-only preflight для DEV перед rollout: + +```sql +SELECT project_id, user_id, count(*) AS pending_count, + array_agg(id ORDER BY id) AS invite_ids +FROM invites_invite +WHERE is_accepted IS NULL +GROUP BY project_id, user_id +HAVING count(*) > 1; +``` + +Если результат непустой, rollout остановлен: сначала отдельное решение и очистка +конфликтов с разрешением владельца данных, затем применение constraint. Migration +сама ничего не удаляет/не отзывает. Между preflight и constraint БД также проверит +конкурентный INSERT; неуспешная migration откатывается целиком. + +При будущем переносе на PROD нужна отдельная адаптация migration graph и predicate +с учётом `is_revoked=False`. Этот DEV constraint нельзя вслепую накладывать поверх +PROD lifecycle: исторические revoked Invite там имеют `is_accepted=NULL`. + +## Acceptance criteria + +| AC | Наблюдаемый результат | Evidence | +| --- | --- | --- | +| B1 | Injected failure после INSERT не оставляет Collaborator | `test_failure_after_collaborator_insert_rolls_back_everything` | +| B2 | Два одновременных POST → 201/409, одна запись | `test_concurrent_create_has_one_201_and_one_409` | +| B3 | Прямой duplicate INSERT блокируется DB | `test_database_rejects_duplicate_pending_but_allows_history` | +| B4 | Проигранный INSERT constraint → 409, не 500 | `test_insert_constraint_conflict_maps_to_409` | +| B5 | Double accept / accept+decline имеют одного победителя | `InviteConcurrencyTests` | +| B6 | Accept+revoke не оставляют partial state | `test_concurrent_accept_revoke_has_no_partial_state` | +| B7 | Stale PATCH / concurrent PATCH не воскресит pending | `InviteIntegrityTests`, `InviteConcurrencyTests` | +| B8 | Membership удалён перед accept → 409, без mutation | `test_membership_removed_before_accept_returns_409` | +| B9 | Forward/backward сохраняют данные; duplicate preflight не выбирает победителя | `PendingInviteMigrationTests` | + +Freeze, multi-link resolver, program-level min/max и exclusivity относятся к PR C. +UI, React, production data, merge и deploy не входят в этот PR. diff --git a/invites/migrations/0003_legacy_pending_invite_constraint.py b/invites/migrations/0003_legacy_pending_invite_constraint.py new file mode 100644 index 00000000..5ea2b91d --- /dev/null +++ b/invites/migrations/0003_legacy_pending_invite_constraint.py @@ -0,0 +1,34 @@ +from django.db import migrations, models + + +def require_no_duplicate_pending(apps, schema_editor): + Invite = apps.get_model("invites", "Invite") + duplicates = list( + Invite.objects.using(schema_editor.connection.alias) + .filter(is_accepted__isnull=True) + .values("project_id", "user_id") + .annotate(count=models.Count("pk")) + .filter(count__gt=1) + .order_by("project_id", "user_id")[:20] + ) + if duplicates: + raise RuntimeError( + "Duplicate pending invites: migration stopped without choosing a winner. " + "Run the read-only query in docs/invite-integrity-api.md and resolve " + f"conflicts separately. First groups: {duplicates}" + ) + + +class Migration(migrations.Migration): + dependencies = [("invites", "0002_invite_specialization")] + operations = [ + migrations.RunPython(require_no_duplicate_pending, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="invite", + constraint=models.UniqueConstraint( + fields=("project", "user"), + condition=models.Q(is_accepted__isnull=True), + name="uniq_legacy_pending_invite", + ), + ), + ] diff --git a/invites/models.py b/invites/models.py index 42118726..b244e94d 100644 --- a/invites/models.py +++ b/invites/models.py @@ -46,11 +46,16 @@ 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()}' class Meta(TypedModelMeta): verbose_name = "Приглашение" verbose_name_plural = "Приглашения" ordering = ["-datetime_created"] + constraints = [ + models.UniqueConstraint( + fields=["project", "user"], + condition=models.Q(is_accepted__isnull=True), + name="uniq_legacy_pending_invite", + ) + ] diff --git a/invites/serializers.py b/invites/serializers.py index 42a032e2..11eb4493 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -1,8 +1,7 @@ -from django.apps import apps from rest_framework import serializers from invites.models import Invite -from projects.models import Collaborator +from projects import team_service from projects.serializers import ProjectListSerializer from users.models import CustomUser from users.serializers import UserDetailSerializer @@ -33,48 +32,16 @@ class Meta: "is_accepted", ] read_only_fields = ["is_accepted"] + # Unique conflict проверяется service под lock и возвращает одинаковый 409. + validators = [] - def validate(self, attrs): - project = attrs["project"] - user = attrs["user"] - - if project.leader_id == user.id: - raise serializers.ValidationError( - {"user": "Пользователь уже является лидером проекта."} - ) - - if Collaborator.objects.filter(project=project, user=user).exists(): - raise serializers.ValidationError( - {"user": "Пользователь уже состоит в проекте."} - ) - - if Invite.objects.filter( - project=project, user=user, is_accepted__isnull=True - ).exists(): - raise serializers.ValidationError( - {"user": "У пользователя уже есть активное приглашение в этот проект."} - ) - - link = project.program_links.select_related("partner_program").first() - if link: - PartnerProgramUserProfile = apps.get_model( - "partner_programs", "PartnerProgramUserProfile" - ) - is_participant = PartnerProgramUserProfile.objects.filter( - user_id=user.id, - partner_program_id=link.partner_program_id, - ).exists() - if not is_participant: - raise serializers.ValidationError( - { - "user": ( - "Нельзя пригласить пользователя: проект относится к программе, " - "а пользователь не является её участником." - ) - } - ) - - return attrs + def create(self, validated_data): + return team_service.create_invite( + project_id=validated_data.pop("project").pk, + user_id=validated_data.pop("user").pk, + actor=self.context["request"].user, + **validated_data, + ) class InviteDetailSerializer(serializers.ModelSerializer[Invite]): @@ -82,7 +49,7 @@ class InviteDetailSerializer(serializers.ModelSerializer[Invite]): project = ProjectListSerializer(many=False, read_only=True) sender = InviteSenderSerializer(source="project.leader", read_only=True) specialization = serializers.CharField( - required=False, allow_null=True, allow_blank=True + required=False, allow_null=True, allow_blank=True, max_length=100 ) class Meta: @@ -106,3 +73,10 @@ class Meta: "datetime_created", "datetime_updated", ] + + def update(self, instance, validated_data): + return team_service.edit_pending_invite( + invite_id=instance.pk, + actor=self.context["request"].user, + **validated_data, + ) diff --git a/invites/tests/test_invite_api.py b/invites/tests/test_invite_api.py index dd2816cb..8d3ef177 100644 --- a/invites/tests/test_invite_api.py +++ b/invites/tests/test_invite_api.py @@ -91,8 +91,8 @@ def test_cannot_invite_project_leader(self): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("user", response.data) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(response.data["code"], "already_project_leader") def test_cannot_invite_existing_collaborator(self): leader = create_user(prefix="leader") @@ -107,8 +107,8 @@ def test_cannot_invite_existing_collaborator(self): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("user", response.data) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(response.data["code"], "already_project_member") def test_cannot_create_duplicate_active_invite(self): leader = create_user(prefix="leader") @@ -123,8 +123,10 @@ def test_cannot_create_duplicate_active_invite(self): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Invite.objects.filter(project=project, user=recipient).count(), 1) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual( + Invite.objects.filter(project=project, user=recipient).count(), 1 + ) def test_program_project_invite_requires_program_membership(self): leader = create_user(prefix="leader") @@ -139,8 +141,8 @@ def test_program_project_invite_requires_program_membership(self): format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("user", response.data) + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertEqual(response.data["code"], "not_program_member") def test_program_member_can_be_invited_to_program_project(self): leader = create_user(prefix="leader") diff --git a/invites/tests/test_invite_integrity.py b/invites/tests/test_invite_integrity.py new file mode 100644 index 00000000..94a3411f --- /dev/null +++ b/invites/tests/test_invite_integrity.py @@ -0,0 +1,207 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from types import SimpleNamespace +from unittest.mock import patch + +from django.db import IntegrityError, close_old_connections, connection, transaction +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.serializers import InviteDetailSerializer +from invites.tests.helpers import ( + add_user_to_program, + create_invite, + create_project, + create_user, + invite_payload, + link_project_to_program, +) +from projects import team_service +from projects.models import Collaborator + + +class InviteIntegrityTests(TestCase): + def setUp(self): + self.invite = create_invite() + self.client = APIClient() + self.client.force_authenticate(self.invite.user) + + def test_failure_after_collaborator_insert_rolls_back_everything(self): + with patch.object(Invite, "save", side_effect=RuntimeError("injected")): + with self.assertRaisesMessage(RuntimeError, "injected"): + self.client.post(f"/invites/{self.invite.pk}/accept/") + self.invite.refresh_from_db() + self.assertIsNone(self.invite.is_accepted) + self.assertFalse( + Collaborator.objects.filter( + project=self.invite.project, user=self.invite.user + ).exists() + ) + + def test_membership_removed_before_accept_returns_409(self): + program = link_project_to_program(project=self.invite.project) + membership = add_user_to_program(user=self.invite.user, program=program) + membership.delete() + response = self.client.post(f"/invites/{self.invite.pk}/accept/") + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "not_program_member") + self.invite.refresh_from_db() + self.assertIsNone(self.invite.is_accepted) + self.assertFalse( + Collaborator.objects.filter( + project=self.invite.project, user=self.invite.user + ).exists() + ) + + def test_stale_serializer_cannot_restore_pending_after_accept(self): + serializer = InviteDetailSerializer( + self.invite, + data={"role": "Changed"}, + partial=True, + context={"request": SimpleNamespace(user=self.invite.project.leader)}, + ) + self.assertTrue(serializer.is_valid()) + self.assertEqual( + self.client.post(f"/invites/{self.invite.pk}/accept/").status_code, 200 + ) + with self.assertRaises(team_service.TeamError) as raised: + serializer.save() + self.assertEqual(raised.exception.detail["code"], "invite_already_processed") + self.invite.refresh_from_db() + self.assertIs(self.invite.is_accepted, True) + self.assertEqual(self.invite.role, "Developer") + + def test_lifecycle_fields_cannot_be_patched(self): + self.client.force_authenticate(self.invite.project.leader) + response = self.client.patch( + f"/invites/{self.invite.pk}/", + {"is_accepted": True, "role": "Engineer"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.invite.refresh_from_db() + self.assertIsNone(self.invite.is_accepted) + self.assertEqual(self.invite.role, "Engineer") + + def test_processed_invite_cannot_be_edited_or_revoked(self): + self.client.post(f"/invites/{self.invite.pk}/decline/") + self.client.force_authenticate(self.invite.project.leader) + for response in ( + self.client.patch(f"/invites/{self.invite.pk}/", {"role": "Changed"}), + self.client.delete(f"/invites/{self.invite.pk}/"), + ): + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "invite_already_processed") + self.invite.refresh_from_db() + self.assertIs(self.invite.is_accepted, False) + + def test_database_rejects_duplicate_pending_but_allows_history(self): + with self.assertRaises(IntegrityError), transaction.atomic(): + create_invite(project=self.invite.project, user=self.invite.user) + create_invite( + project=self.invite.project, user=self.invite.user, is_accepted=False + ) + self.assertEqual( + Invite.objects.filter( + project=self.invite.project, user=self.invite.user + ).count(), + 2, + ) + + def test_insert_constraint_conflict_maps_to_409(self): + # Имитируем устаревший результат предварительного exists; INSERT и constraint реальные. + pending = team_service._pending_invites(self.invite.project, self.invite.user_id) + with patch.object(pending, "exists", side_effect=[False, True]): + with patch.object(team_service, "_pending_invites", return_value=pending): + self.client.force_authenticate(self.invite.project.leader) + response = self.client.post( + "/invites/", invite_payload(self.invite.project, self.invite.user) + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "duplicate_pending_invite") + self.assertEqual(Invite.objects.filter(pk=self.invite.pk).count(), 1) + + +@skipUnlessDBFeature("has_select_for_update") +class InviteConcurrencyTests(TransactionTestCase): + def setUp(self): + self.project = create_project() + self.recipient = create_user() + + def race(self, *requests): + barrier = Barrier(len(requests)) + + def execute(actor, method, url, payload): + close_old_connections() + try: + client = APIClient() + client.force_authenticate(actor) + barrier.wait(timeout=10) + response = getattr(client, method)(url, payload, format="json") + return response.status_code + finally: + connection.close() + + with ThreadPoolExecutor(max_workers=len(requests)) as pool: + futures = [pool.submit(execute, *request) for request in requests] + return [future.result(timeout=20) for future in futures] + + def invite(self): + return create_invite(project=self.project, user=self.recipient) + + def assert_consistent(self, invite_id): + invite = Invite.objects.filter(pk=invite_id).first() + accepted = Collaborator.objects.filter( + project=self.project, user=self.recipient + ).exists() + self.assertEqual(accepted, bool(invite and invite.is_accepted is True)) + + def test_concurrent_create_has_one_201_and_one_409(self): + request = ( + self.project.leader, + "post", + "/invites/", + invite_payload(self.project, self.recipient), + ) + self.assertEqual(sorted(self.race(request, request)), [201, 409]) + self.assertEqual(Invite.objects.filter(project=self.project).count(), 1) + + def test_concurrent_double_accept_creates_one_collaborator(self): + invite = self.invite() + request = (self.recipient, "post", f"/invites/{invite.pk}/accept/", {}) + self.assertEqual(sorted(self.race(request, request)), [200, 409]) + self.assert_consistent(invite.pk) + self.assertEqual( + Collaborator.objects.filter( + project=self.project, user=self.recipient + ).count(), + 1, + ) + + def test_concurrent_accept_decline_has_one_winner(self): + invite = self.invite() + statuses = self.race( + (self.recipient, "post", f"/invites/{invite.pk}/accept/", {}), + (self.recipient, "post", f"/invites/{invite.pk}/decline/", {}), + ) + self.assertEqual(sorted(statuses), [200, 409]) + self.assert_consistent(invite.pk) + + def test_concurrent_accept_revoke_has_no_partial_state(self): + invite = self.invite() + statuses = self.race( + (self.recipient, "post", f"/invites/{invite.pk}/accept/", {}), + (self.project.leader, "delete", f"/invites/{invite.pk}/", {}), + ) + self.assertIn(statuses, ([200, 409], [404, 204])) + self.assert_consistent(invite.pk) + + def test_concurrent_patch_accept_cannot_resurrect_pending(self): + invite = self.invite() + statuses = self.race( + (self.recipient, "post", f"/invites/{invite.pk}/accept/", {}), + (self.project.leader, "patch", f"/invites/{invite.pk}/", {"role": "New"}), + ) + self.assertIn(statuses, ([200, 200], [200, 409])) + self.assert_consistent(invite.pk) diff --git a/invites/tests/test_pending_invite_migration.py b/invites/tests/test_pending_invite_migration.py new file mode 100644 index 00000000..2a89b7d9 --- /dev/null +++ b/invites/tests/test_pending_invite_migration.py @@ -0,0 +1,48 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + +from invites.models import Invite +from invites.tests.helpers import create_invite, create_project, create_user + + +class PendingInviteMigrationTests(TransactionTestCase): + before = ("invites", "0002_invite_specialization") + after = ("invites", "0003_legacy_pending_invite_constraint") + + def setUp(self): + MigrationExecutor(connection).migrate([self.before]) + self.project = create_project() + self.recipient = create_user() + + def tearDown(self): + # Только fixtures этого теста: восстановить схему для остальных тестов suite. + Invite.objects.filter(project=self.project).delete() + MigrationExecutor(connection).migrate([self.after]) + super().tearDown() + + def test_duplicate_preflight_stops_without_choosing_a_winner(self): + first = create_invite(project=self.project, user=self.recipient) + second = create_invite(project=self.project, user=self.recipient) + with self.assertRaisesMessage(RuntimeError, "without choosing a winner"): + MigrationExecutor(connection).migrate([self.after]) + self.assertEqual( + set(Invite.objects.filter(project=self.project).values_list("pk", flat=True)), + {first.pk, second.pk}, + ) + self.assertEqual( + Invite.objects.filter(project=self.project, is_accepted__isnull=True).count(), + 2, + ) + + def test_forward_backward_preserves_invites(self): + pending = create_invite(project=self.project, user=self.recipient) + processed = create_invite( + project=self.project, user=self.recipient, is_accepted=False + ) + for target in (self.after, self.before, self.after): + MigrationExecutor(connection).migrate([target]) + pending.refresh_from_db() + processed.refresh_from_db() + self.assertIsNone(pending.is_accepted) + self.assertIs(processed.is_accepted, False) diff --git a/invites/views.py b/invites/views.py index aeb5edfd..b8e55243 100644 --- a/invites/views.py +++ b/invites/views.py @@ -7,7 +7,7 @@ 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 projects import team_service class InviteList(generics.ListCreateAPIView): @@ -22,19 +22,15 @@ def get_queryset(self): ) def create(self, request, *args, **kwargs): - serializer = InviteListSerializer(data=request.data) + serializer = InviteListSerializer( + data=request.data, context=self.get_serializer_context() + ) serializer.is_valid(raise_exception=True) - 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() - headers = self.get_success_headers(serializer.data) - - # using detailed serializer so that it'll pass User and Project objects detailed - detailed_data = InviteDetailSerializer(instance, data=serializer.data) - detailed_data.is_valid() return Response( - detailed_data.data, status=status.HTTP_201_CREATED, headers=headers + InviteDetailSerializer(instance).data, + status=status.HTTP_201_CREATED, + headers=self.get_success_headers(serializer.data), ) @@ -43,6 +39,9 @@ class InviteDetail(generics.RetrieveUpdateDestroyAPIView): serializer_class = InviteDetailSerializer permission_classes = [InviteDetailPermission] + def perform_destroy(self, instance): + team_service.revoke_invite(invite_id=instance.pk, actor=self.request.user) + class InviteAccept(generics.GenericAPIView): queryset = Invite.objects.get_invite_for_list_view() @@ -50,28 +49,8 @@ class InviteAccept(generics.GenericAPIView): permission_classes = [InviteDecisionPermission] 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, - ) - # 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: - return Response( - {"detail": "User is already a collaborator of this project."}, - status=status.HTTP_409_CONFLICT, - ) - invite.is_accepted = True - invite.save() + invite = self.get_object() + team_service.accept_invite(invite_id=invite.pk, actor=request.user) return Response(status=status.HTTP_200_OK) @@ -82,11 +61,5 @@ class InviteDecline(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() - if invite.is_accepted is not None: - return Response( - {"detail": "Invite has already been processed."}, - status=status.HTTP_409_CONFLICT, - ) - invite.is_accepted = False - invite.save() + team_service.decline_invite(invite_id=invite.pk, actor=request.user) return Response(status=status.HTTP_200_OK) diff --git a/projects/team_service.py b/projects/team_service.py index 67e82e20..43a6b692 100644 --- a/projects/team_service.py +++ b/projects/team_service.py @@ -1,10 +1,13 @@ """Операции над legacy-командой: авторизация и изменения в одной транзакции.""" -from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404 from rest_framework.exceptions import APIException from projects.models import Collaborator, Project +from invites.models import Invite +from partner_programs.models import PartnerProgramUserProfile class TeamError(APIException): @@ -91,3 +94,138 @@ def reject_direct_add(*, project_id, actor): "Для добавления участника отправьте приглашение в проект.", status_code=405, ) + + +def _require_pending(invite): + if invite.is_accepted is not None: + raise TeamError("invite_already_processed", "Приглашение уже обработано.") + + +def _require_recipient(invite, actor): + if not actor or not actor.is_authenticated or invite.user_id != actor.pk: + raise TeamError( + "not_invite_recipient", + "Принять или отклонить приглашение может только его получатель.", + status_code=403, + ) + + +def _require_invite_eligible(project, user_id): + if project.leader_id == user_id: + raise TeamError( + "already_project_leader", "Пользователь уже является лидером проекта." + ) + if Collaborator.objects.filter(project=project, user_id=user_id).exists(): + raise TeamError("already_project_member", "Пользователь уже состоит в проекте.") + # Сохраняем legacy-контекст до отдельного PR с явной связью программы. + link = project.program_links.order_by("pk").first() + if link: + membership = ( + PartnerProgramUserProfile.objects.select_for_update() + .filter(partner_program_id=link.partner_program_id, user_id=user_id) + .first() + ) + if membership is None: + raise TeamError( + "not_program_member", "Пользователь не является участником программы." + ) + + +def _lock_invite(invite_id): + reference = get_object_or_404(Invite.objects.only("project_id"), pk=invite_id) + project = _locked_project(reference.project_id) + # Повторное чтение после Project lock: revoke мог уже удалить запись. + invite = get_object_or_404(Invite.objects.select_for_update(), pk=invite_id) + return project, invite + + +def _pending_invites(project, user_id): + return Invite.objects.filter( + project=project, user_id=user_id, is_accepted__isnull=True + ) + + +@transaction.atomic +def create_invite(*, project_id, user_id, actor, **fields): + project = _locked_project(project_id) + require_team_manager(project, actor) + _require_invite_eligible(project, user_id) + pending = _pending_invites(project, user_id) + if pending.exists(): + raise TeamError( + "duplicate_pending_invite", "У пользователя уже есть активное приглашение." + ) + try: + # Savepoint нужен, чтобы после IntegrityError внешняя транзакция оставалась рабочей. + with transaction.atomic(): + return Invite.objects.create(project=project, user_id=user_id, **fields) + except IntegrityError as error: + if pending.exists(): + raise TeamError( + "duplicate_pending_invite", + "У пользователя уже есть активное приглашение.", + ) from error + raise + + +@transaction.atomic +def edit_pending_invite(*, invite_id, actor, **fields): + project, invite = _lock_invite(invite_id) + require_team_manager(project, actor) + _require_pending(invite) + allowed_fields = {"role", "specialization", "motivational_letter"} + if fields.keys() - allowed_fields: + raise TeamError( + "invalid_invite_fields", "Недопустимые поля приглашения.", status_code=422 + ) + for field, value in fields.items(): + setattr(invite, field, value) + if fields: + invite.save(update_fields=[*fields, "datetime_updated"]) + return invite + + +@transaction.atomic +def accept_invite(*, invite_id, actor): + project, invite = _lock_invite(invite_id) + _require_recipient(invite, actor) + _require_pending(invite) + _require_invite_eligible(project, invite.user_id) + try: + with transaction.atomic(): + Collaborator.objects.create( + project=project, + user_id=invite.user_id, + role=invite.role, + specialization=invite.specialization, + ) + except DjangoValidationError as error: + raise TeamError("invalid_team_member", " ".join(error.messages)) from error + except IntegrityError as error: + if Collaborator.objects.filter(project=project, user_id=invite.user_id).exists(): + raise TeamError( + "already_project_member", "Пользователь уже состоит в проекте." + ) from error + raise + invite.is_accepted = True + invite.save(update_fields=["is_accepted", "datetime_updated"]) + return invite + + +@transaction.atomic +def decline_invite(*, invite_id, actor): + _project, invite = _lock_invite(invite_id) + _require_recipient(invite, actor) + _require_pending(invite) + invite.is_accepted = False + invite.save(update_fields=["is_accepted", "datetime_updated"]) + return invite + + +@transaction.atomic +def revoke_invite(*, invite_id, actor): + project, invite = _lock_invite(invite_id) + require_team_manager(project, actor) + _require_pending(invite) + # Сохраняем DELETE-контракт legacy без переноса unrelated PROD lifecycle полей. + invite.delete()