From 3a513846c81dd36e5f3393957ec3450c9fcfb2f7 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 29 Sep 2026 14:54:09 +0300 Subject: [PATCH 1/2] 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() From 88f114b63b954e3faa578420175f598b63022e9b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 29 Sep 2026 15:54:37 +0300 Subject: [PATCH 2/2] feat: enforce competitive legacy team policy across writers --- docs/competitive-team-policy.md | 140 +++++ docs/legacy-team-data-audit.sql | 44 ++ invites/admin.py | 6 +- .../0004_legacy_project_team_policy.py | 44 ++ invites/models.py | 98 ++- invites/serializers.py | 9 + invites/tests/test_invite_integrity.py | 21 +- .../tests/test_pending_invite_migration.py | 40 +- invites/tests/test_team_policy_migration.py | 55 ++ invites/views.py | 7 +- partner_programs/admin.py | 15 +- .../0019_legacy_project_team_policy.py | 67 +++ partner_programs/models.py | 115 ++++ partner_programs/serializers/programs.py | 6 + partner_programs/services/case_fields.py | 4 +- partner_programs/services/exports.py | 20 +- partner_programs/services/project_apply.py | 17 +- partner_programs/services/project_fields.py | 11 +- partner_programs/tests/helpers.py | 6 +- .../tests/test_case_analytics_api.py | 6 +- .../tests/test_case_lifecycle_api.py | 1 + partner_programs/tests/test_case_locking.py | 2 + .../tests/test_field_value_admin.py | 2 + .../tests/test_field_value_mutation.py | 5 +- .../tests/test_program_link_fields_api.py | 20 +- .../tests/test_program_project_submit.py | 18 +- .../tests/test_project_field_values_api.py | 5 +- .../tests/test_role_widget_api.py | 3 +- projects/admin.py | 5 +- projects/helpers.py | 83 +-- projects/models.py | 91 ++- projects/serializers.py | 36 +- projects/team_admin.py | 77 +++ projects/team_errors.py | 9 + projects/team_policy.py | 317 ++++++++++ projects/team_service.py | 202 +++++-- projects/tests/helpers.py | 5 +- .../tests/test_competitive_team_policy.py | 567 ++++++++++++++++++ .../test_program_project_detail_access.py | 3 +- projects/views.py | 64 +- users/serializers.py | 7 + .../test_project_list_program_contract.py | 8 +- vacancy/response_services.py | 16 +- vacancy/views.py | 6 +- 44 files changed, 2077 insertions(+), 206 deletions(-) create mode 100644 docs/competitive-team-policy.md create mode 100644 docs/legacy-team-data-audit.sql create mode 100644 invites/migrations/0004_legacy_project_team_policy.py create mode 100644 invites/tests/test_team_policy_migration.py create mode 100644 partner_programs/migrations/0019_legacy_project_team_policy.py create mode 100644 projects/team_admin.py create mode 100644 projects/team_errors.py create mode 100644 projects/team_policy.py create mode 100644 projects/tests/test_competitive_team_policy.py diff --git a/docs/competitive-team-policy.md b/docs/competitive-team-policy.md new file mode 100644 index 00000000..aafad10c --- /dev/null +++ b/docs/competitive-team-policy.md @@ -0,0 +1,140 @@ +# Конкурсная legacy-команда (PR C) + +Основание — утверждённое ТЗ hardening и уточнения пользователя от 29.09.2026. +База: PR B `3a513846c81dd36e5f3393957ec3450c9fcfb2f7`. + +## Current / target, роли и правила + +До: legacy submitted не запрещает team mutation, program context выбирался первым +link, нет ограничений размера или конкурентной exclusivity. Target: общая policy +для Invite, Collaborator, switch/leave/remove, vacancy accept, apply/bind/clone, +admin/model validation и link-scoped submission. Управление командой по-прежнему +только у лидера; существующий staff override vacancy service сохраняется. + +- Exclusivity: все draft/submitted Project links конкурсной программы до + `datetime_finished`. Завершённые программы сохраняют историю без exclusivity. +- Freeze: любой submitted competitive link запрещает изменение общего состава + Project; explicit context другой программы не может обойти этот freeze. +- Draft разрешён одному лидеру. Минимум проверяется только на submission. +- Leader входит в accepted unique user IDs один раз. Pending unique recipients + резервируют max capacity, но не учитываются в submission minimum. +- Каждая NULL-граница отключает только своё ограничение. +- Decline/revoke pending разрешены после submission и освобождают reservation. + +## Схема / совместимость до создания migration + +DEV PartnerProgram не содержит min/max. PROD PartnerProgram `team_min_size` и +`team_max_size` зависят от nextgen `participation_format`: запрещены для individual, +обязательны обе для team, каждое >= 2. Это несовместимо с nullable legacy semantics. +Новые настройки: `legacy_team_min_size`, `legacy_team_max_size`, nullable без data +defaults/backfill. Значения nextgen не копируются. Ограничивается только корректность +конфигурации: положительные значения и min <= max, если заданы обе границы. + +Invite получает nullable `program_link → PartnerProgramProject` с RESTRICT: +исторический контекст нельзя потерять из-за удаления link; удаление всего Project +по cascade может удалить и Invite, и link. Новый Invite программного Project хранит +конкретный link. Backfill только для ровно одного link; ноль / несколько остаются NULL. +Для ambiguous legacy Invite accept/edit требуют explicit `program_link_id` либо 409 +`program_context_required`; decline/revoke не добавляют участников и доступны для +безопасного завершения pending. Migration не выбирает программу по минимальному PK. + +## Транзакции и resolver + +Единый порядок существующего Project: `Project → PartnerProgram (sorted IDs) → +PartnerProgramProject (sorted IDs) → Invite / Vacancy → PUP / Collaborator`. +Project lock сериализует смену links и roster. Program locks сериализуют writers +разных проектов одной программы; актуальный PUP блокируется до записи membership. +Создание нового Project при apply начинается с целевой Program: новый Project ещё +не доступен другим writers. Все проверки выполняются повторно внутри transaction. + +Context: ноль links → обычный проект; один → единственная связь; несколько → +explicit ID из этого Project либо 409. Submission сохраняет link ID в URL. +Поскольку Collaborator общий для всех links Project, при добавлении проверяются +membership/exclusivity/capacity всех затронутых программ, а не только выбранной. + +## Acceptance criteria / план проверки + +| AC | Предусловие / действие / результат | +| --- | --- | +| C1 | Submitted: create/edit/accept/add/vacancy/remove/leave/switch → team_frozen | +| C2 | Submitted: pending decline/revoke успешны | +| C3 | Leader/collaborator в A не может войти в B той же активной программы | +| C4 | Разные программы и завершённые программы не дают ложной exclusivity | +| C5 | Два concurrent accept в разные команды одной программы → один успех | +| C6 | Solo draft допустим; configured minimum проверяется на submit | +| C7 | Exact min/max допустимы; max+1 с pending reservation запрещён | +| C8 | Ноль / один / два links, explicit правильный / чужой ID проверены | +| C9 | Membership каждого accepted участника повторно проверяется на submit | +| C10 | Submit+accept / submit+remove не оставляют изменяемую submitted-команду | +| C11 | Apply/create/bind/clone/vacancy/admin/model не обходят policy | +| C12 | Recommendations содержат только actionable candidates | +| C13 | Team-size export считает уникальные accepted IDs | +| C14 | Migration backfill не угадывает контекст и не изменяет roster | +| C15 | Обычные проекты сохраняют прежние успешные flows | + +UI — только отдельный PR D; без redesign и нового recommendations UI. React, +production data, merge, deploy и unrelated analytics не входят в scope. +Data audit и rollout diagnostics не выбирают победителя существующих конфликтов. + +## API contract + +`program_link_id` — optional positive integer: body create/edit/accept Invite, +switch/direct-add; query remove/leave/recommended_users; body bind/update Project +и vacancy accept. Запрос с ID другой связи → 422 `invalid_program_context`. +Сохранённый Invite context нельзя переназначить. Decline/revoke context не требуют. +Link-scoped submission URL не менялся. + +Project list/detail и компактные `/auth/users/projects[/leader]/` возвращают +`team_policy`: `is_frozen`, `program_link_id` только для единственной связи, +`requires_program_context`, `program_links[{id,program_id,is_submitted,is_competitive}]`. +Legacy singular `partner_program` остаётся read compatibility projection; +его first-link presentation не используется для новых business mutations. + +Новые 409: `team_frozen`, `already_in_program_team`, `team_min_size`, +`team_max_size`, `program_context_required`, `program_context_in_use`, +`submission_closed`; existing B codes сохраняются. Permission — 403; +неверный context — 422; malformed input и existing submission/case validation — 400. +Admin показывает Django form validation / controlled conflict вместо 500. + +## DB guarantees и пределы + +DB: existing unique Collaborator(user,project), unique PUP(user,program), +pending Invite constraint PR B; новые FK Invite.program_link и checks корректности +min/max конфигурации. Нет отдельной таблицы с unique(user,program,active_team): +exclusivity по нескольким таблицам и времени гарантируют Program locks и повторная +проверка поддерживаемых writers. Прямой SQL, QuerySet.update/bulk_create/cascade +удаление целых родительских сущностей не являются защищёнными roster-service командами. +Новые maintenance/import writers должны соблюдать service protocol. Для независимой +от writers DB-гарантии потребуется отдельная materialized membership сущность и +согласованная миграция; она не добавляется молча в этот PR. + +## Verification / review + +115 targeted PostgreSQL tests PASS: competitive policy (29 cases), все Invite tests, +forward/backward migrations, admin field mutations и case locking. Среди новых +проверок — real HTTP races: accept A/B, invite capacity, submit/accept, submit/remove; +clone/bind rollback, model/API required fields, independent NULL bounds, DB bound +check, membership removal, explicit foreign context и any-submitted-link freeze. + +Black/Flake8 изменённых/new Python файлов, Django check, migration consistency и +diff check PASS. Полный suite и GitHub PostgreSQL CI фиксируются в PR description. +UI/browser, live DEV/PROD mutations и deploy здесь не проверялись/не выполнялись. + +Старые tests чтения сохраняют намеренно конфликтные historical fixtures через +bulk/queryset insert/update. Это не обход policy в production code: новые mutation +tests вызывают реальные API/service/model save и проверяют rejection/rollback. + +## Rollout + +1. A → dev; затем B → dev после смены base B; затем C → dev после смены base C. +2. До B/C на целевой БД прочитать `legacy-team-data-audit.sql`. Здесь live production + audit не выполнялся. При duplicates migration B остановится, данные сохранятся. +3. C schema/backfill не удаляет конфликтные команды, не задаёт product min/max, + ambiguous Invite оставляет NULL. Existing conflicts требуют отдельного решения + владельца данных; до решения новые conflict-sensitive mutations возвращают 409. +4. После применения schema и backend отдельно внедрить Angular PR D (#392). +5. PROD имеет другой migration graph и nextgen lifecycle. Прямой cherry-pick + migrations без согласования графа не является production release plan. + +Review AC C1–C15: source и targeted assertions проверены; full-suite evidence +добавляется в описание PR после окончания проверки. Merge/deploy выполняет владелец. diff --git a/docs/legacy-team-data-audit.sql b/docs/legacy-team-data-audit.sql new file mode 100644 index 00000000..6ac57034 --- /dev/null +++ b/docs/legacy-team-data-audit.sql @@ -0,0 +1,44 @@ +-- Только чтение. Выполнить до rollout; строки содержат ID, без выбора победителя. +-- Уже существующий один пользователь в нескольких командах активной программы. +WITH members AS ( + SELECT p.id AS project_id, p.leader_id AS user_id FROM projects_project p + UNION + SELECT c.project_id, c.user_id FROM projects_collaborator c +) +SELECT l.partner_program_id, m.user_id, + array_agg(DISTINCT m.project_id ORDER BY m.project_id) AS project_ids +FROM members m +JOIN partner_programs_partnerprogramproject l ON l.project_id = m.project_id +JOIN partner_programs_partnerprogram p ON p.id = l.partner_program_id +WHERE p.is_competitive AND p.datetime_finished > CURRENT_TIMESTAMP +GROUP BY l.partner_program_id, m.user_id +HAVING count(DISTINCT m.project_id) > 1; + +-- Pending duplicates: миграция PR B остановится при непустом результате. +SELECT project_id, user_id, 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; + +-- Ambiguous contexts: migration PR C оставит program_link NULL. +SELECT i.id AS invite_id, i.project_id, + array_agg(l.id ORDER BY l.id) AS possible_program_link_ids +FROM invites_invite i +JOIN partner_programs_partnerprogramproject l ON l.project_id = i.project_id +GROUP BY i.id, i.project_id +HAVING count(l.id) > 1; + +-- Accepted roster без регистрации в конкурсной программе. +WITH members AS ( + SELECT id AS project_id, leader_id AS user_id FROM projects_project + UNION + SELECT project_id, user_id FROM projects_collaborator +) +SELECT l.partner_program_id, m.project_id, m.user_id +FROM members m +JOIN partner_programs_partnerprogramproject l ON l.project_id = m.project_id +JOIN partner_programs_partnerprogram p ON p.id = l.partner_program_id +LEFT JOIN partner_programs_partnerprogramuserprofile u + ON u.partner_program_id = l.partner_program_id AND u.user_id = m.user_id +WHERE p.is_competitive AND u.id IS NULL; diff --git a/invites/admin.py b/invites/admin.py index 30527640..3c1fb4e1 100644 --- a/invites/admin.py +++ b/invites/admin.py @@ -1,12 +1,16 @@ from django.contrib import admin from invites.models import Invite +from projects.team_admin import TeamValidationAdminMixin @admin.register(Invite) -class InviteAdmin(admin.ModelAdmin): +class InviteAdmin(TeamValidationAdminMixin, admin.ModelAdmin): + readonly_fields = ("is_accepted",) + fields = [ "project", + "program_link", "user", "motivational_letter", "role", diff --git a/invites/migrations/0004_legacy_project_team_policy.py b/invites/migrations/0004_legacy_project_team_policy.py new file mode 100644 index 00000000..d27a25e8 --- /dev/null +++ b/invites/migrations/0004_legacy_project_team_policy.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.11 on 2026-09-29 12:08 + +from django.db import migrations, models +import django.db.models.deletion + + +def backfill_unambiguous_context(apps, schema_editor): + Link = apps.get_model("partner_programs", "PartnerProgramProject") + Invite = apps.get_model("invites", "Invite") + database = schema_editor.connection.alias + single_links = ( + Link.objects.using(database) + .values("project_id") + .annotate(link_count=models.Count("pk"), link_id=models.Min("pk")) + .filter(link_count=1) + ) + # Min здесь не выбирает победителя: count=1 доказывает единственность связи. + for row in single_links.iterator(): + Invite.objects.using(database).filter( + project_id=row["project_id"], program_link__isnull=True + ).update(program_link_id=row["link_id"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0019_legacy_project_team_policy"), + ("invites", "0003_legacy_pending_invite_constraint"), + ] + + operations = [ + migrations.AddField( + model_name="invite", + name="program_link", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + related_name="team_invites", + to="partner_programs.partnerprogramproject", + ), + ), + migrations.RunPython(backfill_unambiguous_context, migrations.RunPython.noop), + ] diff --git a/invites/models.py b/invites/models.py index b244e94d..cfbb5b98 100644 --- a/invites/models.py +++ b/invites/models.py @@ -1,4 +1,4 @@ -from django.db import models +from django.db import models, transaction from django_stubs_ext.db.models import TypedModelMeta from invites.managers import InviteManager @@ -21,6 +21,13 @@ class Invite(models.Model): """ project = models.ForeignKey(Project, on_delete=models.CASCADE) + program_link = models.ForeignKey( + "partner_programs.PartnerProgramProject", + null=True, + blank=True, + on_delete=models.RESTRICT, + related_name="team_invites", + ) user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) motivational_letter = models.TextField( @@ -45,6 +52,95 @@ class Invite(models.Model): objects = InviteManager() + def clean(self): + super().clean() + from django.core.exceptions import ValidationError + from projects import team_policy + from projects.team_errors import TeamError + + if not self.project_id or not self.user_id: + return + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + try: + if ( + previous + and previous.program_link_id + and previous.program_link_id != self.program_link_id + ): + raise TeamError( + "invalid_program_context", + "Контекст существующего приглашения нельзя изменить.", + ) + if previous and ( + previous.project_id != self.project_id or previous.user_id != self.user_id + ): + raise TeamError( + "invalid_invite_fields", + "Получателя и проект приглашения нельзя изменить.", + ) + if previous and previous.is_accepted is not None: + if any( + getattr(previous, field) != getattr(self, field) + for field in ( + "role", + "specialization", + "motivational_letter", + "is_accepted", + "program_link_id", + ) + ): + raise TeamError( + "invite_already_processed", "Приглашение уже обработано." + ) + if self.is_accepted is None: + if ( + type(self) + .objects.filter( + project_id=self.project_id, + user_id=self.user_id, + is_accepted__isnull=True, + ) + .exclude(pk=self.pk) + .exists() + ): + raise TeamError( + "duplicate_pending_invite", + "У пользователя уже есть активное приглашение.", + ) + link = team_policy.validate_new_member( + self.project, self.user_id, program_link_id=self.program_link_id + ) + if link: + self.program_link = link + except TeamError as error: + raise ValidationError( + str(error.detail["detail"]), code=str(error.detail["code"]) + ) from error + + @transaction.atomic + def save(self, *args, **kwargs): + from projects.team_policy import lock_team + + self.project = lock_team(self.project_id).project + # В legacy поле nullable, но blank=False; None — валидный pending lifecycle. + self.full_clean( + exclude=["is_accepted"], validate_unique=False, validate_constraints=False + ) + return super().save(*args, **kwargs) + + @transaction.atomic + def delete(self, *args, **kwargs): + from django.core.exceptions import ValidationError + from projects.team_policy import lock_team + + lock_team(self.project_id) + previous = type(self).objects.select_for_update().filter(pk=self.pk).first() + if previous and previous.is_accepted is not None: + raise ValidationError( + "Приглашение уже обработано.", code="invite_already_processed" + ) + return super().delete(*args, **kwargs) + def __str__(self) -> str: return f'Invite from project "{self.project.name}" to {self.user.get_full_name()}' diff --git a/invites/serializers.py b/invites/serializers.py index 11eb4493..c9c1d4ae 100644 --- a/invites/serializers.py +++ b/invites/serializers.py @@ -20,11 +20,16 @@ class Meta: class InviteListSerializer(serializers.ModelSerializer[Invite]): + program_link_id = serializers.IntegerField( + required=False, allow_null=True, min_value=1 + ) + class Meta: model = Invite fields = [ "id", "project", + "program_link_id", "user", "motivational_letter", "role", @@ -45,6 +50,9 @@ def create(self, validated_data): class InviteDetailSerializer(serializers.ModelSerializer[Invite]): + program_link_id = serializers.IntegerField( + required=False, allow_null=True, min_value=1 + ) user = UserDetailSerializer(many=False, read_only=True) project = ProjectListSerializer(many=False, read_only=True) sender = InviteSenderSerializer(source="project.leader", read_only=True) @@ -57,6 +65,7 @@ class Meta: fields = [ "id", "project", + "program_link_id", "user", "sender", "motivational_letter", diff --git a/invites/tests/test_invite_integrity.py b/invites/tests/test_invite_integrity.py index 94a3411f..c475f480 100644 --- a/invites/tests/test_invite_integrity.py +++ b/invites/tests/test_invite_integrity.py @@ -98,7 +98,10 @@ def test_processed_invite_cannot_be_edited_or_revoked(self): 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) + # Проверяем именно DB guarantee, минуя более ранний model guard. + Invite.objects.bulk_create( + [Invite(project=self.invite.project, user=self.invite.user)] + ) create_invite( project=self.invite.project, user=self.invite.user, is_accepted=False ) @@ -112,7 +115,9 @@ def test_database_rejects_duplicate_pending_but_allows_history(self): 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(pending, "exists", side_effect=[False, True]), patch.object( + Invite, "full_clean" + ): with patch.object(team_service, "_pending_invites", return_value=pending): self.client.force_authenticate(self.invite.project.leader) response = self.client.post( @@ -122,6 +127,18 @@ def test_insert_constraint_conflict_maps_to_409(self): self.assertEqual(response.data["code"], "duplicate_pending_invite") self.assertEqual(Invite.objects.filter(pk=self.invite.pk).count(), 1) + def test_model_duplicate_conflict_maps_to_409(self): + pending = team_service._pending_invites(self.invite.project, self.invite.user_id) + with patch.object(pending, "exists", return_value=False), 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") + @skipUnlessDBFeature("has_select_for_update") class InviteConcurrencyTests(TransactionTestCase): diff --git a/invites/tests/test_pending_invite_migration.py b/invites/tests/test_pending_invite_migration.py index 2a89b7d9..73e2d69e 100644 --- a/invites/tests/test_pending_invite_migration.py +++ b/invites/tests/test_pending_invite_migration.py @@ -2,8 +2,7 @@ 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 +from invites.tests.helpers import create_project, create_user class PendingInviteMigrationTests(TransactionTestCase): @@ -11,34 +10,51 @@ class PendingInviteMigrationTests(TransactionTestCase): after = ("invites", "0003_legacy_pending_invite_constraint") def setUp(self): - MigrationExecutor(connection).migrate([self.before]) + executor = MigrationExecutor(connection) + self.latest = executor.loader.graph.leaf_nodes() + executor.migrate([self.before]) + self.Invite = executor.loader.project_state([self.before]).apps.get_model( + "invites", "Invite" + ) 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]) + self.Invite.objects.filter(project_id=self.project.pk).delete() + MigrationExecutor(connection).migrate(self.latest) 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) + first = self.Invite.objects.create( + project_id=self.project.pk, user_id=self.recipient.pk + ) + second = self.Invite.objects.create( + project_id=self.project.pk, user_id=self.recipient.pk + ) 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)), + set( + self.Invite.objects.filter(project_id=self.project.pk).values_list( + "pk", flat=True + ) + ), {first.pk, second.pk}, ) self.assertEqual( - Invite.objects.filter(project=self.project, is_accepted__isnull=True).count(), + self.Invite.objects.filter( + project_id=self.project.pk, 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 + pending = self.Invite.objects.create( + project_id=self.project.pk, user_id=self.recipient.pk + ) + processed = self.Invite.objects.create( + project_id=self.project.pk, user_id=self.recipient.pk, is_accepted=False ) for target in (self.after, self.before, self.after): MigrationExecutor(connection).migrate([target]) diff --git a/invites/tests/test_team_policy_migration.py b/invites/tests/test_team_policy_migration.py new file mode 100644 index 00000000..e00534ed --- /dev/null +++ b/invites/tests/test_team_policy_migration.py @@ -0,0 +1,55 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + +from invites.models import Invite +from partner_programs.models import PartnerProgram +from partner_programs.tests.helpers import create_partner_program, create_program_project +from projects.tests.helpers import create_project, create_user + + +class TeamPolicyMigrationTests(TransactionTestCase): + before = [ + ("invites", "0003_legacy_pending_invite_constraint"), + ("partner_programs", "0018_partnerprogramuserprofile_welcome_acknowledged_at"), + ] + + def test_forward_backfill_only_unique_link_and_reverse_preserves_rows(self): + latest = MigrationExecutor(connection).loader.graph.leaf_nodes() + programs = [create_partner_program(), create_partner_program()] + projects = [create_project() for _ in range(3)] + one = create_program_project(programs[0], project=projects[1]) + create_program_project(programs[0], project=projects[2]) + create_program_project(programs[1], project=projects[2]) + user = create_user() + # Fixtures до миграции: контекст тогда не существовал, model policy не вызываем. + invites = Invite.objects.bulk_create( + [Invite(project=project, user=user) for project in projects] + ) + ids = [invite.pk for invite in invites] + try: + executor = MigrationExecutor(connection) + executor.migrate(self.before) + executor = MigrationExecutor(connection) + OldInvite = executor.loader.project_state(self.before).apps.get_model( + "invites", "Invite" + ) + self.assertEqual(OldInvite.objects.filter(pk__in=ids).count(), 3) + executor.migrate(latest) + self.assertEqual( + list( + Invite.objects.filter(pk__in=ids) + .order_by("pk") + .values_list("program_link_id", flat=True) + ), + [None, one.pk, None], + ) + self.assertFalse( + PartnerProgram.objects.exclude( + legacy_team_min_size=None, legacy_team_max_size=None + ).exists() + ) + MigrationExecutor(connection).migrate(self.before) + self.assertEqual(OldInvite.objects.filter(pk__in=ids).count(), 3) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/invites/views.py b/invites/views.py index b8e55243..595e4fda 100644 --- a/invites/views.py +++ b/invites/views.py @@ -8,6 +8,7 @@ from invites.querysets import get_visible_invites_queryset from invites.serializers import InviteDetailSerializer, InviteListSerializer from projects import team_service +from projects.serializers import requested_program_link class InviteList(generics.ListCreateAPIView): @@ -50,7 +51,11 @@ class InviteAccept(generics.GenericAPIView): def post(self, request, *args, **kwargs): invite = self.get_object() - team_service.accept_invite(invite_id=invite.pk, actor=request.user) + team_service.accept_invite( + invite_id=invite.pk, + actor=request.user, + program_link_id=requested_program_link(request), + ) return Response(status=status.HTTP_200_OK) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 12fbb70c..cf159bd3 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -3,6 +3,7 @@ import tablib from django import forms from django.contrib import admin +from projects.team_admin import TeamValidationAdminMixin from django.db.models import QuerySet from django.http import HttpRequest from django.urls import path @@ -83,6 +84,8 @@ class Meta: "description", "city", "is_competitive", + "legacy_team_min_size", + "legacy_team_max_size", "projects_availability", "publish_projects_after_finish", "max_project_rates", @@ -110,8 +113,10 @@ class Meta: ) def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: - qs = super().get_queryset(request).prefetch_related( - "managers", "materials", "fields" + qs = ( + super() + .get_queryset(request) + .prefetch_related("managers", "materials", "fields") ) if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True @@ -130,9 +135,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None): "partner_programs/admin/program_manager_change_form.html" ) else: - self.change_form_template = ( - "partner_programs/admin/programs_change_form.html" - ) + self.change_form_template = "partner_programs/admin/programs_change_form.html" return super().change_view(request, object_id, form_url, extra_context) @@ -316,7 +319,7 @@ def get_display_value(self, obj): @admin.register(PartnerProgramProject) -class PartnerProgramProjectAdmin(admin.ModelAdmin): +class PartnerProgramProjectAdmin(TeamValidationAdminMixin, admin.ModelAdmin): list_display = ( "id", "project", diff --git a/partner_programs/migrations/0019_legacy_project_team_policy.py b/partner_programs/migrations/0019_legacy_project_team_policy.py new file mode 100644 index 00000000..e792571b --- /dev/null +++ b/partner_programs/migrations/0019_legacy_project_team_policy.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.11 on 2026-09-29 12:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0018_partnerprogramuserprofile_welcome_acknowledged_at"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="legacy_team_max_size", + field=models.PositiveIntegerField( + blank=True, + help_text="NULL — без максимума. Ожидающие приглашения резервируют места.", + null=True, + verbose_name="Максимум участников legacy-команды", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="legacy_team_min_size", + field=models.PositiveIntegerField( + blank=True, + help_text="NULL — без минимума. Проверяется только при сдаче проекта.", + null=True, + verbose_name="Минимум участников legacy-команды", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("legacy_team_min_size__isnull", True), + ("legacy_team_min_size__gte", 1), + _connector="OR", + ), + name="legacy_team_min_positive", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("legacy_team_max_size__isnull", True), + ("legacy_team_max_size__gte", 1), + _connector="OR", + ), + name="legacy_team_max_positive", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("legacy_team_min_size__isnull", True), + ("legacy_team_max_size__isnull", True), + ("legacy_team_max_size__gte", models.F("legacy_team_min_size")), + _connector="OR", + ), + name="legacy_team_max_gte_min", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index f123f078..008d4fb1 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -51,6 +51,18 @@ class PartnerProgram(models.Model): verbose_name="Конкурсная программа", help_text="Если включено, проекты участников подлежат сдаче на проверку", ) + legacy_team_min_size = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name="Минимум участников legacy-команды", + help_text="NULL — без минимума. Проверяется только при сдаче проекта.", + ) + legacy_team_max_size = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name="Максимум участников legacy-команды", + help_text="NULL — без максимума. Ожидающие приглашения резервируют места.", + ) city = models.TextField( verbose_name="Город", ) @@ -150,6 +162,23 @@ class PartnerProgram(models.Model): ) datetime_updated = models.DateTimeField(verbose_name="Дата изменения", auto_now=True) + def clean(self): + super().clean() + for field in ("legacy_team_min_size", "legacy_team_max_size"): + value = getattr(self, field) + if value is not None and value < 1: + raise ValidationError( + {field: "Размер команды должен быть положительным."} + ) + if ( + self.legacy_team_min_size is not None + and self.legacy_team_max_size is not None + and self.legacy_team_min_size > self.legacy_team_max_size + ): + raise ValidationError( + {"legacy_team_max_size": "Максимум не может быть меньше минимума."} + ) + def is_manager(self, user: User) -> bool: """ Возвращает True, если пользователь — менеджер этой программы. @@ -159,6 +188,24 @@ def is_manager(self, user: User) -> bool: return self.managers.filter(pk=user.pk).exists() class Meta: + constraints = [ + models.CheckConstraint( + check=models.Q(legacy_team_min_size__isnull=True) + | models.Q(legacy_team_min_size__gte=1), + name="legacy_team_min_positive", + ), + models.CheckConstraint( + check=models.Q(legacy_team_max_size__isnull=True) + | models.Q(legacy_team_max_size__gte=1), + name="legacy_team_max_positive", + ), + models.CheckConstraint( + check=models.Q(legacy_team_min_size__isnull=True) + | models.Q(legacy_team_max_size__isnull=True) + | models.Q(legacy_team_max_size__gte=models.F("legacy_team_min_size")), + name="legacy_team_max_gte_min", + ), + ] verbose_name = "Программа" verbose_name_plural = "Программы" @@ -296,6 +343,74 @@ class PartnerProgramProject(models.Model): null=True, blank=True, verbose_name="Дата сдачи проекта" ) + def clean(self): + super().clean() + from projects import team_policy + from projects.team_errors import TeamError + + if not self.project_id or not self.partner_program_id: + return + previous = type(self).objects.filter(pk=self.pk).first() if self.pk else None + try: + if previous and ( + previous.project_id != self.project_id + or previous.partner_program_id != self.partner_program_id + ): + raise TeamError( + "invalid_program_context", + "Связь проекта с программой нельзя переназначить; создайте новую связь.", + ) + if previous is None: + team_policy.validate_project_binding(self.project, self.partner_program) + if previous and previous.submitted and not self.submitted: + raise TeamError( + "team_frozen", + "Сданный проект нельзя вернуть в draft изменением связи.", + ) + if self.submitted and (previous is None or not previous.submitted): + if not self.partner_program.is_competitive: + raise TeamError( + "invalid_submission", "Программа не является конкурсной." + ) + if not self.partner_program.is_project_submission_open(): + raise TeamError("submission_closed", "Срок подачи проектов завершён.") + team_policy.validate_submission_team(self.project, self) + from django.db import connection + from partner_programs.services.case_fields import ( + validate_case_before_submission, + ) + + validate_case_before_submission( + self, for_update=connection.in_atomic_block + ) + team_policy.validate_required_program_fields(self) + except TeamError as error: + raise ValidationError( + str(error.detail["detail"]), code=str(error.detail["code"]) + ) from error + + @transaction.atomic + def save(self, *args, **kwargs): + from projects.team_policy import lock_team + + context = lock_team(self.project_id, extra_program_ids=[self.partner_program_id]) + self.project = context.project + self.partner_program = context.programs[self.partner_program_id] + self.clean() + return super().save(*args, **kwargs) + + @transaction.atomic + def delete(self, *args, **kwargs): + from projects.team_policy import lock_team + + context = lock_team(self.project_id) + current = next((link for link in context.links if link.pk == self.pk), None) + if current and current.submitted and current.partner_program.is_competitive: + raise ValidationError( + "Сданную связь программы нельзя удалить.", code="team_frozen" + ) + return super().delete(*args, **kwargs) + def can_edit(self, user: User) -> bool: if not user or not user.is_authenticated: return False diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index 57e15a65..a91dda5b 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -76,6 +76,8 @@ class Meta: "datetime_project_submission_ends", "datetime_evaluation_ends", "publish_projects_after_finish", + "legacy_team_min_size", + "legacy_team_max_size", "datetime_started", "datetime_finished", "views_count", @@ -184,6 +186,8 @@ class Meta: "datetime_project_submission_ends", "datetime_evaluation_ends", "publish_projects_after_finish", + "legacy_team_min_size", + "legacy_team_max_size", "is_user_manager", "welcome_acknowledged_at", "courses", @@ -210,6 +214,8 @@ class Meta: "datetime_project_submission_ends", "datetime_evaluation_ends", "publish_projects_after_finish", + "legacy_team_min_size", + "legacy_team_max_size", "is_user_manager", "courses", ) diff --git a/partner_programs/services/case_fields.py b/partner_programs/services/case_fields.py index 7888c6e1..42e25950 100644 --- a/partner_programs/services/case_fields.py +++ b/partner_programs/services/case_fields.py @@ -76,9 +76,9 @@ def validate_case_value(field, value): ) -def validate_case_before_submission(program_project): +def validate_case_before_submission(program_project, *, for_update=True): """Lock config inside the caller's transaction; validate this link only.""" - field = get_program_case_field(program_project.partner_program, for_update=True) + field = get_program_case_field(program_project.partner_program, for_update=for_update) if field is None: return value = ( diff --git a/partner_programs/services/exports.py b/partner_programs/services/exports.py index a2ddb7e4..992bc56f 100644 --- a/partner_programs/services/exports.py +++ b/partner_programs/services/exports.py @@ -171,18 +171,12 @@ def _leader_full_name(user): def _calc_team_size(project): - prefetched_collaborators = getattr(project, "_prefetched_collaborators", None) - if prefetched_collaborators is not None: - return 1 + len(prefetched_collaborators) + prefetched = getattr(project, "_prefetched_collaborators", None) + if prefetched is not None: + return len({project.leader_id} | {member.user_id for member in prefetched}) + from projects.team_policy import accepted_user_ids - try: - if hasattr(project, "get_collaborators_user_list"): - return 1 + len(project.get_collaborators_user_list()) - if hasattr(project, "collaborator_set"): - return 1 + project.collaborator_set.count() - except Exception: - pass - return 1 + return len(accepted_user_ids(project)) def _team_members(project) -> str: @@ -505,9 +499,7 @@ def prepare_project_scores_export_data(program_id: int) -> list[dict]: for _, expert_scores in scores_by_expert.items(): row_data: dict[str, str] = {} - row_data["Название проекта"] = ( - getattr(project, "name", "") if project else "" - ) + row_data["Название проекта"] = getattr(project, "name", "") if project else "" row_data["Фамилия эксперта"] = ( expert_scores[0].user.last_name if expert_scores else "" ) diff --git a/partner_programs/services/project_apply.py b/partner_programs/services/project_apply.py index d7c3fb19..da341f54 100644 --- a/partner_programs/services/project_apply.py +++ b/partner_programs/services/project_apply.py @@ -61,7 +61,9 @@ def _validate_unique_program_fields(values_data: list[dict]) -> None: seen_field_ids.add(field_id) if duplicate_ids: raise ValidationError( - {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} + { + "program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}" + } ) @@ -84,7 +86,9 @@ def _validate_required_program_fields( ] if missing_required: raise ValidationError( - {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} + { + "program_field_values": f"Не заполнены обязательные поля: {missing_required}" + } ) @@ -103,6 +107,7 @@ def _validate_program_field_ownership( ) +@transaction.atomic def apply_project_to_program( *, program: PartnerProgram, @@ -110,6 +115,9 @@ def apply_project_to_program( data, serializer_class, ) -> ProgramProjectApplicationResult: + from projects import team_policy + + program = PartnerProgram.objects.select_for_update().get(pk=program.pk) require_can_apply_project_to_program(program=program, user=user) existing_link = ( @@ -120,6 +128,11 @@ def apply_project_to_program( if existing_link: raise ProgramProjectAlreadyApplied(existing_link) + if program.is_competitive: + team_policy.validate_program_users( + program, project_id=None, user_ids={user.pk}, lock=True + ) + serializer = serializer_class(data=data, context={"program": program}) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py index 92e75bdc..0e7c4f2b 100644 --- a/partner_programs/services/project_fields.py +++ b/partner_programs/services/project_fields.py @@ -111,7 +111,11 @@ def update_program_link_fields(link_id, user, data): @transaction.atomic def submit_program_project(link_id, user): """Freeze a link only after current case validation, under the same lock as PUT.""" - link = get_program_link(link_id, for_update=True) + from projects import team_policy + + reference = get_object_or_404(PartnerProgramProject, pk=link_id) + context = team_policy.lock_team(reference.project_id) + link = team_policy.resolve_program_context(context.project, link_id) require_link_leader(link, user) if not link.partner_program.is_competitive: raise ValidationError({"detail": "Программа не является конкурсной."}) @@ -123,6 +127,11 @@ def submit_program_project(link_id, user): validate_case_before_submission(link) except DjangoValidationError as error: raise ValidationError({"detail": error.messages[0]}) + team_policy.validate_submission_team(context.project, link) + try: + team_policy.validate_required_program_fields(link) + except DjangoValidationError as error: + raise ValidationError({"detail": error.messages[0]}) link.submitted = True link.datetime_submitted = timezone.now() link.save(update_fields=["submitted", "datetime_submitted", "datetime_updated"]) diff --git a/partner_programs/tests/helpers.py b/partner_programs/tests/helpers.py index a1863790..87158710 100644 --- a/partner_programs/tests/helpers.py +++ b/partner_programs/tests/helpers.py @@ -105,12 +105,16 @@ def create_program_project( project: Project | None = None, submitted: bool = False, ) -> PartnerProgramProject: - return PartnerProgramProject.objects.create( + # Исторические fixtures чтения могут предшествовать новой team policy. + # Проверки новых mutations используют реальные save/service отдельно. + link = PartnerProgramProject( partner_program=program, project=project or create_project(), submitted=submitted, datetime_submitted=timezone.now() if submitted else None, ) + PartnerProgramProject.objects.bulk_create([link]) + return link def create_course(program: PartnerProgram, **overrides) -> Course: diff --git a/partner_programs/tests/test_case_analytics_api.py b/partner_programs/tests/test_case_analytics_api.py index fb3f9606..c45c6385 100644 --- a/partner_programs/tests/test_case_analytics_api.py +++ b/partner_programs/tests/test_case_analytics_api.py @@ -129,7 +129,11 @@ def test_membership_is_unique_within_case_and_registered_in_this_program(self): (second_link.project, second), (second_link.project, third), ): - Collaborator.objects.get_or_create(project=project, user=user) + # Legacy конфликт двух команд нужен для read-only аналитики. + if not Collaborator.objects.filter(project=project, user=user).exists(): + Collaborator.objects.bulk_create( + [Collaborator(project=project, user=user)] + ) # Only bypass validation to represent a pre-existing outsider membership. Collaborator.objects.bulk_create( [Collaborator(project=first.project, user=outsider)] diff --git a/partner_programs/tests/test_case_lifecycle_api.py b/partner_programs/tests/test_case_lifecycle_api.py index 154ce70c..b73e57de 100644 --- a/partner_programs/tests/test_case_lifecycle_api.py +++ b/partner_programs/tests/test_case_lifecycle_api.py @@ -137,6 +137,7 @@ def test_submit_valid_case_freezes_fields(self): def test_other_program_case_does_not_satisfy_current_link_submission(self): link = self.link() program_b = create_partner_program(is_competitive=True) + create_program_member(program_b, user=self.user) field_b = create_case_field(program_b, options=["Other"]) link_b = create_program_project(program_b, project=link.project) PartnerProgramFieldValue.objects.create( diff --git a/partner_programs/tests/test_case_locking.py b/partner_programs/tests/test_case_locking.py index 9baf9de4..d39f40db 100644 --- a/partner_programs/tests/test_case_locking.py +++ b/partner_programs/tests/test_case_locking.py @@ -18,6 +18,7 @@ ) from partner_programs.tests.helpers import ( create_partner_program, + create_program_member, create_program_project, create_project, create_user, @@ -30,6 +31,7 @@ class ProgramCaseLockingTests(TransactionTestCase): def setUp(self): self.user = create_user() self.program = create_partner_program(is_competitive=True) + create_program_member(self.program, user=self.user) self.link = create_program_project( self.program, project=create_project(leader=self.user) ) diff --git a/partner_programs/tests/test_field_value_admin.py b/partner_programs/tests/test_field_value_admin.py index 77c6d84a..7cabcce3 100644 --- a/partner_programs/tests/test_field_value_admin.py +++ b/partner_programs/tests/test_field_value_admin.py @@ -7,6 +7,7 @@ from partner_programs.models import PartnerProgramFieldValue from partner_programs.tests.helpers import ( create_partner_program, + create_program_member, create_program_field, create_program_project, create_user, @@ -29,6 +30,7 @@ def setUp(self): self.client.force_login(self.user) self.program = create_partner_program(is_competitive=True) self.link = create_program_project(self.program) + create_program_member(self.program, user=self.link.project.leader) self.field = ( create_case_field(self.program, options=["rgdftb", "A", "B"]) if self.is_case diff --git a/partner_programs/tests/test_field_value_mutation.py b/partner_programs/tests/test_field_value_mutation.py index 49da935a..3abb80b4 100644 --- a/partner_programs/tests/test_field_value_mutation.py +++ b/partner_programs/tests/test_field_value_mutation.py @@ -137,7 +137,10 @@ def test_noncompetitive_submission_does_not_freeze_generic_or_case_values(self): self.program.is_competitive = False self.program.save(update_fields=["is_competitive"]) self.link.submitted = True - self.link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(self.link).objects.filter(pk=self.link.pk).update( + submitted=self.link.submitted + ) for value in self.values: value.value_text = "B" value.full_clean() diff --git a/partner_programs/tests/test_program_link_fields_api.py b/partner_programs/tests/test_program_link_fields_api.py index 5312dfe9..82793df1 100644 --- a/partner_programs/tests/test_program_link_fields_api.py +++ b/partner_programs/tests/test_program_link_fields_api.py @@ -124,7 +124,10 @@ def test_competitive_closed_metadata(self): def test_already_submitted_metadata_keeps_window_open_but_cannot_submit(self): self.link.submitted = True - self.link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(self.link).objects.filter(pk=self.link.pk).update( + submitted=self.link.submitted + ) self.assert_submission_metadata( self.link, competitive=True, @@ -163,7 +166,10 @@ def test_metadata_uses_requested_link_when_project_has_different_program_states( self.program.datetime_project_submission_ends = closed_deadline self.program.save(update_fields=["datetime_project_submission_ends"]) self.link.submitted = True - self.link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(self.link).objects.filter(pk=self.link.pk).update( + submitted=self.link.submitted + ) # B is deliberately not the first link, and must not inherit A's closed/submitted state. self.assert_submission_metadata( self.other_link, @@ -268,7 +274,10 @@ def save(value, *args, **kwargs): def test_submitted_competitive_link_blocks_all_field_updates(self): extra = create_program_field(self.program) self.link.submitted = True - self.link.save() + # Уже существовавшая сданная связь для проверки запрета редактирования полей. + type(self.link).objects.filter(pk=self.link.pk).update( + submitted=self.link.submitted + ) for field, value in ((self.field, "B"), (extra, "text")): self.assertEqual( self.put([{"field_id": field.pk, "value_text": value}]).status_code, 400 @@ -282,7 +291,10 @@ def test_get_allows_existing_project_involvement_and_current_program_roles(self) teammate = create_user() create_program_member(self.program, user=teammate) create_program_member(self.other_program, user=teammate) - Collaborator.objects.create(user=teammate, project=self.project) + # Явный контекст для двух программ одного Project. + member = Collaborator(user=teammate, project=self.project) + member._program_link_id = self.link.pk + member.save() for user in ( self.leader, manager, diff --git a/partner_programs/tests/test_program_project_submit.py b/partner_programs/tests/test_program_project_submit.py index 3208c4f3..a5f153f2 100644 --- a/partner_programs/tests/test_program_project_submit.py +++ b/partner_programs/tests/test_program_project_submit.py @@ -4,6 +4,7 @@ from partner_programs.tests.helpers import ( create_partner_program, + create_program_member, create_program_project, create_project, create_user, @@ -25,6 +26,7 @@ def create_program(self, **overrides): return create_partner_program(**defaults) def create_project_link(self, program): + create_program_member(program, user=self.user) project = create_project(leader=self.user, draft=False, is_public=False) return create_program_project(program, project=project) @@ -35,7 +37,9 @@ def test_submit_blocked_after_deadline(self): link = self.create_project_link(program) self.client.force_authenticate(self.user) - response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + response = self.client.post( + f"/programs/partner-program-projects/{link.pk}/submit/" + ) self.assertEqual(response.status_code, 400) self.assertEqual( @@ -50,7 +54,9 @@ def test_submit_allowed_before_deadline(self): link = self.create_project_link(program) self.client.force_authenticate(self.user) - response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + response = self.client.post( + f"/programs/partner-program-projects/{link.pk}/submit/" + ) self.assertEqual(response.status_code, 200) link.refresh_from_db() @@ -62,7 +68,9 @@ def test_submit_rejects_non_competitive_program(self): link = self.create_project_link(program) self.client.force_authenticate(self.user) - response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + response = self.client.post( + f"/programs/partner-program-projects/{link.pk}/submit/" + ) self.assertEqual(response.status_code, 400) self.assertEqual(response.data["detail"], "Программа не является конкурсной.") @@ -73,6 +81,8 @@ def test_submit_rejects_non_leader(self): outsider = create_user(prefix="submit-program-outsider") self.client.force_authenticate(outsider) - response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + response = self.client.post( + f"/programs/partner-program-projects/{link.pk}/submit/" + ) self.assertEqual(response.status_code, 403) diff --git a/partner_programs/tests/test_project_field_values_api.py b/partner_programs/tests/test_project_field_values_api.py index 640f5ccf..b27fc750 100644 --- a/partner_programs/tests/test_project_field_values_api.py +++ b/partner_programs/tests/test_project_field_values_api.py @@ -53,7 +53,10 @@ def test_submitted_competitive_project_fields_cannot_be_changed(self): self.program.is_competitive = True self.program.save(update_fields=["is_competitive"]) self.program_link.submitted = True - self.program_link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(self.program_link).objects.filter(pk=self.program_link.pk).update( + submitted=self.program_link.submitted + ) self.client.force_authenticate(self.leader) response = self.client.put( diff --git a/partner_programs/tests/test_role_widget_api.py b/partner_programs/tests/test_role_widget_api.py index c9f6384b..3702fe2b 100644 --- a/partner_programs/tests/test_role_widget_api.py +++ b/partner_programs/tests/test_role_widget_api.py @@ -185,7 +185,8 @@ def test_distributed_stages_zero_partial_complete_and_not_ready(self): ) self.assertEqual(self.widget(self.member)["participant"]["stage"], "evaluated") link.submitted = False - link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(link).objects.filter(pk=link.pk).update(submitted=link.submitted) self.assertEqual(self.widget(experts[0])["expert"]["remaining"], 1) self.assertEqual( self.widget(self.member)["participant"]["stage"], "not_submitted" diff --git a/projects/admin.py b/projects/admin.py index d9739705..c1fb7c02 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from projects.team_admin import TeamValidationAdminMixin from projects.models import ( Achievement, @@ -48,7 +49,7 @@ def get_formset(self, request, obj=None, **kwargs): @admin.register(Project) -class ProjectAdmin(admin.ModelAdmin): +class ProjectAdmin(TeamValidationAdminMixin, admin.ModelAdmin): list_display = ( "id", "name", @@ -171,7 +172,7 @@ class ProjectLinkAdmin(admin.ModelAdmin): @admin.register(Collaborator) -class CollaboratorAdmin(admin.ModelAdmin): +class CollaboratorAdmin(TeamValidationAdminMixin, admin.ModelAdmin): list_display = ( "id", "user", diff --git a/projects/helpers.py b/projects/helpers.py index 269d1305..2a258e67 100644 --- a/projects/helpers.py +++ b/projects/helpers.py @@ -1,23 +1,17 @@ from random import sample -from django.db import transaction -from django.utils import timezone from django.contrib.auth import get_user_model -from rest_framework.exceptions import ValidationError from partner_programs.models import ( - PartnerProgram, - PartnerProgramProject, PartnerProgramUserProfile, ) from projects.models import Project, ProjectLink, Achievement -from users.models import CustomUser User = get_user_model() -def get_recommended_users(project: Project) -> list[User]: +def get_recommended_users(project: Project, *, program_link_id=None) -> list[User]: """ Searches for users by matching their skills and vacancies required_skills """ @@ -27,7 +21,12 @@ def get_recommended_users(project: Project) -> list[User]: all_needed_skills.update(set(vacancy.get_required_skills())) recommended_users = [] - for user in User.objects.get_members(): + from projects.team_policy import actionable_users + + candidates = actionable_users( + project, User.objects.get_members(), program_link_id=program_link_id + ) + for user in candidates: if user == project.leader or user.skills_count < 1: continue @@ -95,71 +94,21 @@ def update_links(links, pk): ) -@transaction.atomic -def update_partner_program( - program_id: int, - user: CustomUser, - instance: Project, -) -> None: - """ - According to the current logic, 1 user project can be linked to only 1 program. - The user cannot select a ready program, but can edit a project with a ready program - (if the time period allows access). - If he changes the program (completed), he will not be able to return it. - """ - if program_id is not None: - # If the user removes the tag, frontend sends `int -> 0` (id == 0 cannot exist). - if program_id == 0: - clear_project_existing_from_profile(user, instance) - PartnerProgramProject.objects.filter(project=instance).delete() - else: - partner_program = PartnerProgram.objects.get(pk=program_id) - existing_program_profile = ( - PartnerProgramUserProfile.objects.select_related("partner_program") - .filter(user=user, project=instance) - .first() - ) - existing_program_id: int | None = ( - existing_program_profile.partner_program_id - if existing_program_profile - else None - ) - - submission_deadline = partner_program.get_project_submission_deadline() - if submission_deadline and submission_deadline < timezone.now(): - raise ValidationError({"error": "Срок подачи проектов в программу завершён."}) - - if ( - partner_program.datetime_finished < timezone.now() - and (existing_program_id != program_id) - ): - raise ValidationError({"error": "Cannot select a completed program."}) - - clear_project_existing_from_profile(user, instance) - instance.is_public = False - instance.save(update_fields=["is_public"]) - - PartnerProgramProject.objects.filter(project=instance).exclude( - partner_program_id=partner_program.id - ).delete() - PartnerProgramProject.objects.get_or_create( - partner_program=partner_program, project=instance - ) +def update_partner_program(program_id, user, instance, program_link_id=None): + from projects.team_service import bind_project_to_program - partner_program_profile = PartnerProgramUserProfile.objects.filter( - user=user, - partner_program=partner_program, - ).first() - if partner_program_profile: - partner_program_profile.project = instance - partner_program_profile.save(update_fields=["project"]) + return bind_project_to_program( + project_id=instance.pk, + program_id=program_id, + actor=user, + program_link_id=program_link_id, + ) def clear_project_existing_from_profile(user, instance) -> None | int: """Remove project from `PartnerProgramUserProfile` instance.""" existing_program_profile = ( - PartnerProgramUserProfile.objects - .select_related("partner_program") + PartnerProgramUserProfile.objects.select_related("partner_program") .filter(user=user, project=instance) .first() ) diff --git a/projects/models.py b/projects/models.py index a8d9833f..caa1ee38 100644 --- a/projects/models.py +++ b/projects/models.py @@ -1,6 +1,5 @@ from typing import Optional -from django.apps import apps from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.validators import ( @@ -8,7 +7,7 @@ MaxValueValidator, MinValueValidator, ) -from django.db import models +from django.db import models, transaction from django.db.models import UniqueConstraint from files.models import UserFile @@ -203,8 +202,27 @@ def get_collaborators_user_list(self) -> list[User]: def __str__(self): return f"Project<{self.id}> - {self.name}" + def clean(self): + super().clean() + from projects.team_policy import validate_model_leader + from projects.team_errors import TeamError + + try: + validate_model_leader(self) + except TeamError as error: + raise ValidationError( + str(error.detail["detail"]), code=str(error.detail["code"]) + ) from error + + @transaction.atomic def save(self, *args, **kwargs): """Set random cover and avatar images if not provided.""" + if self.pk: + from projects.team_policy import lock_team + + self._team_context = lock_team(self.pk) + if kwargs.get("update_fields") is None or "leader" in kwargs["update_fields"]: + self.clean() if not self.cover_image_address: self.cover_image_address = DefaultProjectCover.get_random_file_link() @@ -324,33 +342,58 @@ class Meta: ] def clean(self): - """ - Если проект привязан к программе, добавлять коллаборатора можно - только если пользователь — участник этой программы. - (Проект привязан максимум к одной программе.) - """ - link = self.project.program_links.select_related("partner_program").first() - if not link: - return + super().clean() + from projects.team_policy import validate_model_member + from projects.team_errors import TeamError - PartnerProgramUserProfile = apps.get_model( - "partner_programs", - "PartnerProgramUserProfile", - ) + if not self.project_id or not self.user_id: + return + try: + validate_model_member(self) + except TeamError as error: + raise ValidationError( + str(error.detail["detail"]), code=str(error.detail["code"]) + ) from error - is_participant = PartnerProgramUserProfile.objects.filter( - user_id=self.user_id, - partner_program_id=link.partner_program_id, - ).exists() + def save(self, *args, **kwargs): + from projects.team_policy import lock_team - if not is_participant: - raise ValidationError( - "Пользователь не является участником программы, к которой относится проект." + with transaction.atomic(): + context_id = getattr(self, "_program_link_id", None) or getattr( + self.project, "_team_program_link_id", None ) + self.project = lock_team(self.project_id).project + if context_id: + self._program_link_id = context_id + self.full_clean() + return super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + from projects.team_policy import ( + lock_team, + resolve_program_context, + require_mutable_team, + ) + from projects.team_errors import TeamError - def save(self, *args, **kwargs): - self.full_clean() - return super().save(*args, **kwargs) + with transaction.atomic(): + context_id = getattr(self, "_program_link_id", None) or getattr( + self.project, "_team_program_link_id", None + ) + project = lock_team(self.project_id).project + try: + resolve_program_context(project, context_id) + require_mutable_team(project) + if self.user_id == project.leader_id: + raise TeamError( + "leader_cannot_leave", + "Перед удалением участника передайте лидерство.", + ) + except TeamError as error: + raise ValidationError( + str(error.detail["detail"]), code=str(error.detail["code"]) + ) from error + return super().delete(*args, **kwargs) class ProjectGoal(models.Model): diff --git a/projects/serializers.py b/projects/serializers.py index dc6f5aef..5459cf39 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -234,7 +234,29 @@ def update(self, instance, validated_data): return instance -class ProjectDetailSerializer(serializers.ModelSerializer): +class ProgramContextSerializer(serializers.Serializer): + program_link_id = serializers.IntegerField( + required=False, allow_null=True, min_value=1 + ) + + +def requested_program_link(request, *, query=False): + serializer = ProgramContextSerializer( + data=request.query_params if query else request.data + ) + serializer.is_valid(raise_exception=True) + return serializer.validated_data.get("program_link_id") + + +class TeamPolicySerializerMixin: + def get_team_policy(self, project): + from projects.team_policy import policy_snapshot + + return policy_snapshot(project) + + +class ProjectDetailSerializer(TeamPolicySerializerMixin, serializers.ModelSerializer): + team_policy = serializers.SerializerMethodField() is_default_cover = serializers.SerializerMethodField() achievements = AchievementListSerializer(many=True, read_only=True) cover = UserFileSerializer(required=False) @@ -308,14 +330,18 @@ def count_views(self, project): return get_views_count(project) def update(self, instance, validated_data): - instance = super().update(instance, validated_data) - instance.save() + for field, value in validated_data.items(): + setattr(instance, field, value) + # Read-only leader нельзя перезаписать устаревшим объектом после switch. + if validated_data: + instance.save(update_fields=[*validated_data, "datetime_updated"]) return instance class Meta: model = Project fields = [ "id", + "team_policy", "name", "description", "short_description", @@ -359,7 +385,8 @@ class Meta: ] -class ProjectListSerializer(serializers.ModelSerializer): +class ProjectListSerializer(TeamPolicySerializerMixin, serializers.ModelSerializer): + team_policy = serializers.SerializerMethodField() views_count = serializers.SerializerMethodField(method_name="count_views") short_description = serializers.SerializerMethodField() partner_program = serializers.SerializerMethodField() @@ -395,6 +422,7 @@ class Meta: model = Project fields = [ "id", + "team_policy", "name", "leader", "short_description", diff --git a/projects/team_admin.py b/projects/team_admin.py new file mode 100644 index 00000000..5f1c4aa1 --- /dev/null +++ b/projects/team_admin.py @@ -0,0 +1,77 @@ +"""Admin показывает validation errors; concurrent conflict не становится 500.""" + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.http import JsonResponse + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Project +from projects.team_errors import TeamError + + +class TeamValidationAdminMixin: + @transaction.atomic + def changeform_view(self, request, object_id=None, *args, **kwargs): + try: + if request.method == "POST": + from projects.team_policy import lock_team + + obj = self.get_object(request, object_id) if object_id else None + project_id = ( + (obj.pk if isinstance(obj, Project) else obj.project_id) + if obj + else request.POST.get("project") + ) + program_id = request.POST.get("partner_program") + if project_id and str(project_id).isdigit(): + # До form/inline clean: порядок совпадает с API submission. + lock_team( + project_id, + extra_program_ids=[int(program_id)] + if program_id and program_id.isdigit() + else [], + ) + return super().changeform_view(request, object_id, *args, **kwargs) + except TeamError as error: + return JsonResponse(error.detail, status=error.status_code) + except ValidationError as error: + return JsonResponse( + {"code": "team_validation", "detail": error.messages}, status=409 + ) + + def delete_view(self, request, *args, **kwargs): + try: + return super().delete_view(request, *args, **kwargs) + except ValidationError as error: + return JsonResponse( + {"code": "team_validation", "detail": error.messages}, status=409 + ) + + def changelist_view(self, request, *args, **kwargs): + try: + return super().changelist_view(request, *args, **kwargs) + except ValidationError as error: + return JsonResponse( + {"code": "team_validation", "detail": error.messages}, status=409 + ) + + @transaction.atomic + def delete_queryset(self, request, queryset): + objects = list(queryset.order_by("pk")) + project_ids = { + obj.pk if isinstance(obj, Project) else obj.project_id for obj in objects + } + # Bulk delete сначала берёт все Project, затем все Program в общем порядке. + list( + Project.objects.select_for_update().filter(pk__in=project_ids).order_by("pk") + ) + program_ids = PartnerProgramProject.objects.filter( + project_id__in=project_ids + ).values("partner_program_id") + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=program_ids) + .order_by("pk") + ) + for obj in objects: + obj.delete() diff --git a/projects/team_errors.py b/projects/team_errors.py new file mode 100644 index 00000000..40c965af --- /dev/null +++ b/projects/team_errors.py @@ -0,0 +1,9 @@ +from rest_framework.exceptions import APIException + + +class TeamError(APIException): + status_code = 409 + + def __init__(self, code, detail, *, status_code=409): + self.status_code = status_code + super().__init__({"code": code, "detail": detail}, code=code) diff --git a/projects/team_policy.py b/projects/team_policy.py new file mode 100644 index 00000000..5e182128 --- /dev/null +++ b/projects/team_policy.py @@ -0,0 +1,317 @@ +"""Общие правила legacy-команды; HTTP-адаптеры не выбирают программу сами.""" + +from dataclasses import dataclass + +from django.db import connection +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from invites.models import Invite +from partner_programs.models import ( + PartnerProgram, + PartnerProgramUserProfile, +) +from projects.models import Collaborator, Project +from projects.team_errors import TeamError + + +@dataclass +class TeamContext: + project: Project + links: list + programs: dict + + +def lock_team(project_id, *, extra_program_ids=()): + """Вызывать внутри atomic. Project защищает также добавление/удаление links.""" + project = get_object_or_404(Project.objects.select_for_update(), pk=project_id) + program_ids = set( + project.program_links.values_list("partner_program_id", flat=True) + ) | set(extra_program_ids) + programs = { + program.pk: program + for program in PartnerProgram.objects.select_for_update() + .filter(pk__in=program_ids) + .order_by("pk") + } + links = list(project.program_links.select_for_update().order_by("pk")) + for link in links: + link.project = project + link.partner_program = programs[link.partner_program_id] + context = TeamContext(project, links, programs) + project._team_context = context + return context + + +def context_for(project): + context = getattr(project, "_team_context", None) + if context is not None: + return context + cached = getattr(project, "_prefetched_objects_cache", {}).get("program_links") + links = ( + list(cached) + if cached is not None + else list(project.program_links.select_related("partner_program").order_by("pk")) + ) + return TeamContext( + project, links, {link.partner_program_id: link.partner_program for link in links} + ) + + +def resolve_program_context(project, program_link_id=None): + links = context_for(project).links + if program_link_id is None: + program_link_id = getattr(project, "_team_program_link_id", None) + if program_link_id is not None: + try: + program_link_id = int(program_link_id) + except (ValueError, TypeError): + raise TeamError( + "invalid_program_context", + "Укажите корректный ID связи программы.", + status_code=422, + ) + for link in links: + if link.pk == program_link_id: + project._team_program_link_id = link.pk + return link + raise TeamError( + "invalid_program_context", + "Связь программы не принадлежит этому проекту.", + status_code=422, + ) + if len(links) > 1: + raise TeamError( + "program_context_required", "Укажите конкретную связь проекта с программой." + ) + return links[0] if links else None + + +def require_mutable_team(project): + if any( + link.submitted and link.partner_program.is_competitive + for link in context_for(project).links + ): + raise TeamError("team_frozen", "Состав команды зафиксирован после сдачи проекта.") + + +def accepted_user_ids(project): + return set(project.collaborator_set.values_list("user_id", flat=True)) | { + project.leader_id + } + + +def reserved_user_ids(project): + return accepted_user_ids(project) | set( + Invite.objects.filter(project=project, is_accepted__isnull=True).values_list( + "user_id", flat=True + ) + ) + + +def has_active_exclusivity(program): + return program.is_competitive and program.datetime_finished > timezone.now() + + +def conflicting_team_users(program, *, project_id, user_ids): + if not has_active_exclusivity(program): + return set() + other_projects = Project.objects.filter( + program_links__partner_program=program + ).exclude(pk=project_id) + return set( + other_projects.filter(leader_id__in=user_ids).values_list("leader_id", flat=True) + ) | set( + Collaborator.objects.filter( + project__in=other_projects, user_id__in=user_ids + ).values_list("user_id", flat=True) + ) + + +def validate_program_users(program, *, project_id, user_ids, lock=False): + memberships = PartnerProgramUserProfile.objects.filter( + partner_program=program, user_id__in=user_ids + ) + if lock: + memberships = memberships.select_for_update().order_by("pk") + registered = set(memberships.values_list("user_id", flat=True)) + if set(user_ids) - registered: + raise TeamError( + "not_program_member", + "Каждый участник команды должен быть зарегистрирован в программе.", + ) + if conflicting_team_users(program, project_id=project_id, user_ids=user_ids): + raise TeamError( + "already_in_program_team", + "Пользователь уже состоит в другой команде этой программы.", + ) + + +def validate_max_size(program, user_ids): + if ( + program.is_competitive + and program.legacy_team_max_size is not None + and len(user_ids) > program.legacy_team_max_size + ): + raise TeamError( + "team_max_size", + f"Максимальный размер команды — {program.legacy_team_max_size}; ожидающие приглашения резервируют места.", + ) + + +def validate_new_member(project, user_id, *, program_link_id=None): + link = resolve_program_context(project, program_link_id) + require_mutable_team(project) + for program in context_for(project).programs.values(): + validate_program_users( + program, + project_id=project.pk, + user_ids={user_id}, + lock=connection.in_atomic_block, + ) + validate_max_size(program, reserved_user_ids(project) | {user_id}) + return link + + +def validate_submission_team(project, link): + members = accepted_user_ids(project) + validate_program_users( + link.partner_program, + project_id=project.pk, + user_ids=members, + lock=connection.in_atomic_block, + ) + program = link.partner_program + if ( + program.legacy_team_min_size is not None + and len(members) < program.legacy_team_min_size + ): + raise TeamError( + "team_min_size", + f"Для сдачи проекта нужно не менее {program.legacy_team_min_size} участников.", + ) + validate_max_size(program, members) + return members + + +def validate_project_binding(project, program): + require_mutable_team(project) + if program.is_competitive: + validate_program_users( + program, + project_id=project.pk, + user_ids=accepted_user_ids(project), + lock=connection.in_atomic_block, + ) + validate_max_size(program, reserved_user_ids(project)) + + +def validate_required_program_fields(link): + from django.core.exceptions import ValidationError + + fields = link.partner_program.fields.filter(is_required=True).order_by("pk") + if connection.in_atomic_block: + fields = fields.select_for_update() + required = list(fields) + values = ( + dict( + link.field_values.filter(field__in=required).values_list( + "field_id", "value_text" + ) + ) + if link.pk + else {} + ) + if any( + field.pk not in values + or values[field.pk] is None + or not str(values[field.pk]).strip() + for field in required + ): + raise ValidationError("Не заполнены обязательные поля программы.") + + +def actionable_users(project, users, *, program_link_id=None): + resolve_program_context(project, program_link_id) + try: + require_mutable_team(project) + reserved = reserved_user_ids(project) + for program in context_for(project).programs.values(): + validate_max_size(program, reserved | {-1}) + except TeamError: + return users.none() + users = users.exclude(pk__in=reserved) + for program in context_for(project).programs.values(): + users = users.filter(partner_program_profiles__partner_program=program) + if has_active_exclusivity(program): + others = Project.objects.filter( + program_links__partner_program=program + ).exclude(pk=project.pk) + users = users.exclude( + Q(pk__in=others.values("leader_id")) + | Q( + pk__in=Collaborator.objects.filter(project__in=others).values( + "user_id" + ) + ) + ) + return users.distinct() + + +def validate_model_member(member): + previous = Collaborator.objects.filter(pk=member.pk).first() if member.pk else None + if previous and ( + previous.project_id != member.project_id or previous.user_id != member.user_id + ): + raise TeamError( + "invalid_team_member", + "Участника нельзя переназначить другому пользователю или проекту.", + ) + changed = previous is None or any( + getattr(previous, field) != getattr(member, field) + for field in ("project_id", "user_id", "role", "specialization") + ) + if changed: + validate_new_member( + member.project, + member.user_id, + program_link_id=getattr(member, "_program_link_id", None), + ) + else: + resolve_program_context(member.project, getattr(member, "_program_link_id", None)) + + +def validate_model_leader(project): + previous = Project.objects.filter(pk=project.pk).first() if project.pk else None + if previous and previous.leader_id != project.leader_id: + require_mutable_team(project) + if not Collaborator.objects.filter( + project=project, user_id=project.leader_id + ).exists(): + raise TeamError( + "collaborator_not_found", + "Новый лидер должен быть участником команды.", + status_code=422, + ) + validate_new_member(project, project.leader_id) + + +def policy_snapshot(project): + links = context_for(project).links + return { + "is_frozen": any( + link.submitted and link.partner_program.is_competitive for link in links + ), + "program_link_id": links[0].pk if len(links) == 1 else None, + "requires_program_context": len(links) > 1, + "program_links": [ + { + "id": link.pk, + "program_id": link.partner_program_id, + "is_submitted": link.submitted, + "is_competitive": link.partner_program.is_competitive, + } + for link in links + ], + } diff --git a/projects/team_service.py b/projects/team_service.py index 43a6b692..c146b7cb 100644 --- a/projects/team_service.py +++ b/projects/team_service.py @@ -3,19 +3,16 @@ 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 projects.models import Collaborator +from partner_programs.models import ( + PartnerProgramProject, + PartnerProgramUserProfile, +) +from django.utils import timezone from invites.models import Invite -from partner_programs.models import PartnerProgramUserProfile - - -class TeamError(APIException): - status_code = 409 - - def __init__(self, code, detail, *, status_code=409): - self.status_code = status_code - super().__init__({"code": code, "detail": detail}, code=code) +from projects import team_policy +from projects.team_errors import TeamError def require_team_manager(project, actor): @@ -28,7 +25,7 @@ def require_team_manager(project, actor): def _locked_project(project_id): - return get_object_or_404(Project.objects.select_for_update(), pk=project_id) + return team_policy.lock_team(project_id).project def _locked_member(project, user_id): @@ -43,6 +40,7 @@ def _locked_member(project, user_id): "Пользователь не является участником проекта.", status_code=422, ) + member.project = project return member @@ -56,24 +54,29 @@ def _require_not_leader(project, user_id): @transaction.atomic -def remove_member(*, project_id, user_id, actor): +def remove_member(*, project_id, user_id, actor, program_link_id=None): project = _locked_project(project_id) require_team_manager(project, actor) + team_policy.resolve_program_context(project, program_link_id) + team_policy.require_mutable_team(project) _require_not_leader(project, user_id) _locked_member(project, user_id).delete() @transaction.atomic -def leave_team(*, project_id, actor): +def leave_team(*, project_id, actor, program_link_id=None): project = _locked_project(project_id) + team_policy.resolve_program_context(project, program_link_id) + team_policy.require_mutable_team(project) _require_not_leader(project, actor.pk) _locked_member(project, actor.pk).delete() @transaction.atomic -def switch_leader(*, project_id, user_id, actor): +def switch_leader(*, project_id, user_id, actor, program_link_id=None): project = _locked_project(project_id) require_team_manager(project, actor) + team_policy.validate_new_member(project, user_id, program_link_id=program_link_id) if project.leader_id == user_id: raise TeamError( "already_project_leader", @@ -86,9 +89,11 @@ def switch_leader(*, project_id, user_id, actor): @transaction.atomic -def reject_direct_add(*, project_id, actor): +def reject_direct_add(*, project_id, actor, program_link_id=None): project = _locked_project(project_id) require_team_manager(project, actor) + team_policy.resolve_program_context(project, program_link_id) + team_policy.require_mutable_team(project) raise TeamError( "direct_member_add_unsupported", "Для добавления участника отправьте приглашение в проект.", @@ -117,18 +122,6 @@ def _require_invite_eligible(project, user_id): ) 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): @@ -139,16 +132,43 @@ def _lock_invite(invite_id): return project, invite +def _invite_context(project, invite, program_link_id=None): + if invite.program_link_id is not None: + if program_link_id is not None and str(program_link_id) != str( + invite.program_link_id + ): + raise TeamError( + "invalid_program_context", + "Приглашение относится к другой связи программы.", + status_code=422, + ) + program_link_id = invite.program_link_id + return team_policy.resolve_program_context(project, program_link_id) + + def _pending_invites(project, user_id): return Invite.objects.filter( project=project, user_id=user_id, is_accepted__isnull=True ) +def _model_business_error(error): + errors = ( + [item for values in error.error_dict.values() for item in values] + if hasattr(error, "error_dict") + else error.error_list + ) + code = next((item.code for item in errors if item.code), "invalid_invite_fields") + return TeamError(code, " ".join(error.messages)) + + @transaction.atomic -def create_invite(*, project_id, user_id, actor, **fields): +def create_invite(*, project_id, user_id, actor, program_link_id=None, **fields): project = _locked_project(project_id) require_team_manager(project, actor) + link = team_policy.validate_new_member( + project, user_id, program_link_id=program_link_id + ) _require_invite_eligible(project, user_id) pending = _pending_invites(project, user_id) if pending.exists(): @@ -158,7 +178,11 @@ def create_invite(*, project_id, user_id, actor, **fields): try: # Savepoint нужен, чтобы после IntegrityError внешняя транзакция оставалась рабочей. with transaction.atomic(): - return Invite.objects.create(project=project, user_id=user_id, **fields) + return Invite.objects.create( + project=project, user_id=user_id, program_link=link, **fields + ) + except DjangoValidationError as error: + raise _model_business_error(error) from error except IntegrityError as error: if pending.exists(): raise TeamError( @@ -169,10 +193,15 @@ def create_invite(*, project_id, user_id, actor, **fields): @transaction.atomic -def edit_pending_invite(*, invite_id, actor, **fields): +def edit_pending_invite(*, invite_id, actor, program_link_id=None, **fields): project, invite = _lock_invite(invite_id) require_team_manager(project, actor) _require_pending(invite) + link = _invite_context(project, invite, program_link_id) + team_policy.require_mutable_team(project) + team_policy.validate_new_member( + project, invite.user_id, program_link_id=link.pk if link else None + ) allowed_fields = {"role", "specialization", "motivational_letter"} if fields.keys() - allowed_fields: raise TeamError( @@ -180,16 +209,24 @@ def edit_pending_invite(*, invite_id, actor, **fields): ) for field, value in fields.items(): setattr(invite, field, value) - if fields: - invite.save(update_fields=[*fields, "datetime_updated"]) + if fields or invite.program_link_id != (link.pk if link else None): + invite.program_link = link + try: + invite.save(update_fields=[*fields, "program_link", "datetime_updated"]) + except DjangoValidationError as error: + raise _model_business_error(error) from error return invite @transaction.atomic -def accept_invite(*, invite_id, actor): +def accept_invite(*, invite_id, actor, program_link_id=None): project, invite = _lock_invite(invite_id) _require_recipient(invite, actor) _require_pending(invite) + link = _invite_context(project, invite, program_link_id) + team_policy.validate_new_member( + project, invite.user_id, program_link_id=link.pk if link else None + ) _require_invite_eligible(project, invite.user_id) try: with transaction.atomic(): @@ -208,7 +245,8 @@ def accept_invite(*, invite_id, actor): ) from error raise invite.is_accepted = True - invite.save(update_fields=["is_accepted", "datetime_updated"]) + invite.program_link = link + invite.save(update_fields=["is_accepted", "program_link", "datetime_updated"]) return invite @@ -229,3 +267,97 @@ def revoke_invite(*, invite_id, actor): _require_pending(invite) # Сохраняем DELETE-контракт legacy без переноса unrelated PROD lifecycle полей. invite.delete() + + +@transaction.atomic +def add_member( + *, + project_id, + user_id, + actor, + role=None, + specialization=None, + program_link_id=None, + allow_staff=False +): + project = _locked_project(project_id) + if not (allow_staff and (actor.is_staff or actor.is_superuser)): + require_team_manager(project, actor) + team_policy.validate_new_member(project, user_id, program_link_id=program_link_id) + _require_invite_eligible(project, user_id) + try: + with transaction.atomic(): + return Collaborator.objects.create( + project=project, user_id=user_id, role=role, specialization=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=user_id).exists(): + raise TeamError( + "already_project_member", "Пользователь уже состоит в проекте." + ) from error + raise + + +@transaction.atomic +def bind_project_to_program(*, project_id, program_id, actor, program_link_id=None): + if program_id is None: + return + try: + program_id = int(program_id) + except (TypeError, ValueError): + raise TeamError( + "invalid_program_context", "Укажите корректную программу.", status_code=422 + ) + context = team_policy.lock_team( + project_id, extra_program_ids=[program_id] if program_id else [] + ) + project = context.project + require_team_manager(project, actor) + current_link = team_policy.resolve_program_context(project, program_link_id) + team_policy.require_mutable_team(project) + program = context.programs.get(program_id) if program_id else None + if program_id and program is None: + raise TeamError( + "invalid_program_context", "Программа не найдена.", status_code=422 + ) + if program: + if ( + not program.is_project_submission_open() + or program.datetime_finished < timezone.now() + ): + raise TeamError( + "submission_closed", "Срок подачи проектов в программу завершён." + ) + if ( + not program.is_manager(actor) + and not PartnerProgramUserProfile.objects.filter( + partner_program=program, user=actor + ).exists() + ): + raise TeamError( + "not_program_member", + "Подача проекта доступна только участникам программы.", + ) + team_policy.validate_project_binding(project, program) + if current_link and current_link.partner_program_id != program_id: + if current_link.team_invites.exists(): + raise TeamError( + "program_context_in_use", + "Связь программы используется приглашениями; её нельзя удалить.", + ) + PartnerProgramUserProfile.objects.filter( + project=project, partner_program_id=current_link.partner_program_id + ).update(project=None) + current_link.delete() + if program: + link, _created = PartnerProgramProject.objects.get_or_create( + project=project, partner_program=program + ) + project.is_public = False + project.save(update_fields=["is_public"]) + PartnerProgramUserProfile.objects.filter( + user=actor, partner_program=program + ).update(project=project) + return link diff --git a/projects/tests/helpers.py b/projects/tests/helpers.py index 1e6dee01..8bf3eaac 100644 --- a/projects/tests/helpers.py +++ b/projects/tests/helpers.py @@ -161,11 +161,14 @@ def link_project_to_program( *, submitted: bool = False, ) -> PartnerProgramProject: - return PartnerProgramProject.objects.create( + # Историческая связь для read-contract tests, включая конфликтные legacy данные. + link = PartnerProgramProject( project=project, partner_program=program, submitted=submitted, ) + PartnerProgramProject.objects.bulk_create([link]) + return link def create_company(*, name: str = "Company", inn: str | None = None) -> Company: diff --git a/projects/tests/test_competitive_team_policy.py b/projects/tests/test_competitive_team_policy.py new file mode 100644 index 00000000..1ecea9b9 --- /dev/null +++ b/projects/tests/test_competitive_team_policy.py @@ -0,0 +1,567 @@ +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.utils import timezone +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests import test_invite_integrity as integrity +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.exports import _calc_team_size +from partner_programs.tests.helpers import project_apply_payload +from partner_programs.tests.helpers import create_program_field +from projects import team_policy +from projects.models import Collaborator, Project +from projects.tests.helpers import ( + add_program_member, + create_collaborator, + create_partner_program, + create_project, + create_user, +) +from vacancy.tests.helpers import create_vacancy, create_vacancy_response + + +class TeamPolicyFixtures: + def team(self, program=None): + program = program or create_partner_program(is_competitive=True) + leader = create_user() + add_program_member(program, leader) + project = create_project(leader=leader) + link = PartnerProgramProject.objects.create( + project=project, partner_program=program + ) + return project, link + + def registered(self, program): + user = create_user() + add_program_member(program, user) + return user + + def request(self, actor, method, url, data=None): + client = APIClient() + client.force_authenticate(actor) + return getattr(client, method)(url, data or {}, format="json") + + def invite(self, project, user, **data): + response = self.request( + project.leader, + "post", + "/invites/", + {"project": project.pk, "user": user.pk, "role": "Member", **data}, + ) + self.assertEqual(response.status_code, 201, response.data) + return Invite.objects.get(pk=response.data["id"]) + + def submit(self, project, link): + return self.request( + project.leader, + "post", + f"/programs/partner-program-projects/{link.pk}/submit/", + ) + + def assert_conflict(self, response, code): + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], code) + + +class CompetitiveTeamPolicyTests(TeamPolicyFixtures, TestCase): + def setUp(self): + self.project, self.link = self.team() + self.program = self.link.partner_program + + def test_freeze_all_legacy_roster_operations(self): + member = self.registered(self.program) + create_collaborator(self.project, user=member) + recipient = self.registered(self.program) + pending = self.invite(self.project, recipient) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + requests = [ + ( + self.project.leader, + "post", + "/invites/", + {"project": self.project.pk, "user": recipient.pk}, + ), + (self.project.leader, "patch", f"/invites/{pending.pk}/", {"role": "New"}), + (recipient, "post", f"/invites/{pending.pk}/accept/", {}), + ( + self.project.leader, + "delete", + f"/projects/{self.project.pk}/collaborators/?id={member.pk}", + {}, + ), + (member, "delete", f"/projects/{self.project.pk}/collaborators/leave/", {}), + ( + self.project.leader, + "patch", + f"/projects/{self.project.pk}/collaborators/{member.pk}/switch-leader/", + {}, + ), + ( + self.project.leader, + "post", + f"/projects/{self.project.pk}/collaborators/", + {}, + ), + ] + for args in requests: + with self.subTest(method=args[1], url=args[2]): + self.assert_conflict(self.request(*args), "team_frozen") + self.assertEqual( + team_policy.accepted_user_ids(self.project), + {self.project.leader_id, member.pk}, + ) + pending.refresh_from_db() + self.assertIsNone(pending.is_accepted) + + def test_decline_and_revoke_pending_are_allowed_after_submission(self): + recipient = self.registered(self.program) + decline = self.invite(self.project, recipient) + revoke = self.invite(self.project, self.registered(self.program)) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + self.assertEqual( + self.request( + recipient, "post", f"/invites/{decline.pk}/decline/" + ).status_code, + 200, + ) + self.assertEqual( + self.request( + self.project.leader, "delete", f"/invites/{revoke.pk}/" + ).status_code, + 204, + ) + self.assertFalse(Invite.objects.filter(pk=revoke.pk).exists()) + + def test_collaborator_and_leader_cannot_accept_other_team(self): + other, _ = self.team(self.program) + user = self.registered(self.program) + pending = self.invite(other, user) + create_collaborator(self.project, user=user) + self.assert_conflict( + self.request(user, "post", f"/invites/{pending.pk}/accept/"), + "already_in_program_team", + ) + self.assert_conflict( + self.request( + other.leader, + "post", + "/invites/", + {"project": other.pk, "user": self.project.leader_id}, + ), + "already_in_program_team", + ) + + def test_collaborator_cannot_apply_or_create_own_program_project(self): + user = self.registered(self.program) + create_collaborator(self.project, user=user) + before = Project.objects.count() + self.assert_conflict( + self.request( + user, + "post", + f"/programs/{self.program.pk}/projects/apply/", + project_apply_payload(), + ), + "already_in_program_team", + ) + self.assert_conflict( + self.request( + user, + "post", + "/projects/", + {"name": "Own", "partner_program_id": self.program.pk}, + ), + "already_in_program_team", + ) + self.assertEqual(Project.objects.count(), before) + + def test_membership_in_different_program_is_allowed(self): + other, other_link = self.team() + user = self.registered(self.program) + create_collaborator(self.project, user=user) + add_program_member(other_link.partner_program, user) + pending = self.invite(other, user) + self.assertEqual( + self.request(user, "post", f"/invites/{pending.pk}/accept/").status_code, 200 + ) + + def test_finished_program_does_not_enforce_exclusivity(self): + other, _ = self.team(self.program) + self.program.datetime_finished = timezone.now() - timezone.timedelta(seconds=1) + self.program.save(update_fields=["datetime_finished"]) + pending = self.invite(other, self.project.leader) + self.assertEqual( + self.request( + self.project.leader, "post", f"/invites/{pending.pk}/accept/" + ).status_code, + 200, + ) + + def test_minimum_only_on_submit_pending_not_counted(self): + self.program.legacy_team_min_size = 2 + self.program.save(update_fields=["legacy_team_min_size"]) + recipient = self.registered(self.program) + pending = self.invite(self.project, recipient) + self.assertTrue(self.project.draft) + self.assert_conflict(self.submit(self.project, self.link), "team_min_size") + self.assertEqual( + self.request(recipient, "post", f"/invites/{pending.pk}/accept/").status_code, + 200, + ) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + + def test_maximum_reserves_pending_capacity_and_counts_leader_once(self): + self.program.legacy_team_max_size = 2 + self.program.save(update_fields=["legacy_team_max_size"]) + first = self.registered(self.program) + second = self.registered(self.program) + pending = self.invite(self.project, first) + self.assertEqual(_calc_team_size(self.project), 1) + self.assert_conflict( + self.request( + self.project.leader, + "post", + "/invites/", + {"project": self.project.pk, "user": second.pk}, + ), + "team_max_size", + ) + self.assertEqual( + self.request(first, "post", f"/invites/{pending.pk}/accept/").status_code, 200 + ) + self.assertEqual(_calc_team_size(self.project), 2) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + + def test_accept_checks_current_maximum_after_configuration_changed(self): + recipient = self.registered(self.program) + pending = self.invite(self.project, recipient) + self.program.legacy_team_max_size = 1 + self.program.save(update_fields=["legacy_team_max_size"]) + self.assert_conflict( + self.request(recipient, "post", f"/invites/{pending.pk}/accept/"), + "team_max_size", + ) + + def test_submission_validates_current_memberships_and_maximum(self): + member = self.registered(self.program) + create_collaborator(self.project, user=member) + membership = PartnerProgramUserProfile.objects.get( + partner_program=self.program, user=member + ) + membership.delete() + self.assert_conflict(self.submit(self.project, self.link), "not_program_member") + add_program_member(self.program, member) + self.program.legacy_team_max_size = 1 + self.program.save(update_fields=["legacy_team_max_size"]) + self.assert_conflict(self.submit(self.project, self.link), "team_max_size") + + def test_both_null_preserve_solo_submission(self): + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + + def test_program_bounds_validate_independent_nulls_and_db_order(self): + for minimum, maximum in ((None, 1), (2, None), (None, None), (2, 2)): + self.program.legacy_team_min_size = minimum + self.program.legacy_team_max_size = maximum + self.program.clean() + self.program.legacy_team_max_size = 1 + with self.assertRaises(ValidationError): + self.program.clean() + with self.assertRaises(IntegrityError), transaction.atomic(): + type(self.program).objects.filter(pk=self.program.pk).update( + legacy_team_min_size=2, legacy_team_max_size=1 + ) + + def test_model_and_api_submission_require_current_required_fields(self): + field = create_program_field(self.program, is_required=True) + self.link.submitted = True + with self.assertRaisesMessage(ValidationError, "обязательные поля"): + self.link.save(update_fields=["submitted"]) + response = self.submit(self.project, self.link) + self.assertEqual(response.status_code, 400) + self.link.refresh_from_db() + self.assertFalse(self.link.submitted) + field.delete() + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + + def test_clone_same_program_rolls_back_project_and_copied_roster(self): + source = create_project(leader=self.project.leader) + before = Project.objects.count() + response = self.request( + self.project.leader, + "post", + "/projects/assign-to-program/", + { + "project_id": source.pk, + "partner_program_id": self.program.pk, + }, + ) + self.assert_conflict(response, "already_in_program_team") + self.assertEqual(Project.objects.count(), before) + + def test_bind_checks_every_existing_member_and_rolls_back(self): + other = create_project() + add_program_member(self.program, other.leader) + create_collaborator(other, user=self.project.leader) + response = self.request( + other.leader, + "patch", + f"/projects/{other.pk}/", + { + "partner_program_id": self.program.pk, + }, + ) + self.assert_conflict(response, "already_in_program_team") + self.assertFalse(other.program_links.exists()) + + def test_pending_edit_after_membership_removal_is_controlled(self): + user = self.registered(self.program) + pending = self.invite(self.project, user) + PartnerProgramUserProfile.objects.filter( + user=user, partner_program=self.program + ).delete() + self.assert_conflict( + self.request( + self.project.leader, + "patch", + f"/invites/{pending.pk}/", + {"role": "Changed"}, + ), + "not_program_member", + ) + + def test_vacancy_accept_checks_exclusivity(self): + other, _ = self.team(self.program) + vacancy = create_vacancy(project=self.project) + response = create_vacancy_response(vacancy=vacancy, user=other.leader) + self.assert_conflict( + self.request( + self.project.leader, "post", f"/vacancies/responses/{response.pk}/accept/" + ), + "already_in_program_team", + ) + response.refresh_from_db() + self.assertIsNone(response.is_approved) + + def test_switch_leader_rechecks_membership(self): + member = self.registered(self.program) + create_collaborator(self.project, user=member) + PartnerProgramUserProfile.objects.filter( + user=member, partner_program=self.program + ).delete() + self.assert_conflict( + self.request( + self.project.leader, + "patch", + f"/projects/{self.project.pk}/collaborators/{member.pk}/switch-leader/", + ), + "not_program_member", + ) + + def test_explicit_other_link_cannot_bypass_submitted_shared_team(self): + other_program = create_partner_program(is_competitive=True) + add_program_member(other_program, self.project.leader) + other_link = PartnerProgramProject.objects.create( + project=self.project, partner_program=other_program + ) + user = self.registered(self.program) + add_program_member(other_program, user) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + self.assert_conflict( + self.request( + self.project.leader, + "post", + "/invites/", + { + "project": self.project.pk, + "user": user.pk, + "program_link_id": other_link.pk, + }, + ), + "team_frozen", + ) + + def test_multi_link_requires_explicit_context_and_stores_link(self): + other_program = create_partner_program(is_competitive=True) + add_program_member(other_program, self.project.leader) + other_link = PartnerProgramProject.objects.create( + project=self.project, partner_program=other_program + ) + user = self.registered(self.program) + add_program_member(other_program, user) + payload = {"project": self.project.pk, "user": user.pk} + self.assert_conflict( + self.request(self.project.leader, "post", "/invites/", payload), + "program_context_required", + ) + pending = self.invite(self.project, user, program_link_id=other_link.pk) + self.assertEqual(pending.program_link_id, other_link.pk) + self.assertEqual( + self.request(user, "post", f"/invites/{pending.pk}/accept/").status_code, 200 + ) + + def test_explicit_link_from_other_project_is_rejected(self): + other, link = self.team() + response = self.request( + self.project.leader, + "post", + "/invites/", + { + "project": self.project.pk, + "user": self.registered(self.program).pk, + "program_link_id": link.pk, + }, + ) + self.assertEqual(response.status_code, 422) + self.assertEqual(response.data["code"], "invalid_program_context") + + def test_ordinary_project_needs_no_program_context(self): + project = create_project() + user = create_user() + pending = self.invite(project, user) + self.assertIsNone(pending.program_link_id) + self.assertEqual( + self.request(user, "post", f"/invites/{pending.pk}/accept/").status_code, 200 + ) + + def test_vacancy_accept_cannot_bypass_freeze(self): + candidate = self.registered(self.program) + vacancy = create_vacancy(project=self.project) + response = create_vacancy_response(vacancy=vacancy, user=candidate) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + self.assert_conflict( + self.request( + self.project.leader, "post", f"/vacancies/responses/{response.pk}/accept/" + ), + "team_frozen", + ) + response.refresh_from_db() + self.assertIsNone(response.is_approved) + + def test_model_member_change_cannot_bypass_freeze(self): + member = self.registered(self.program) + row = create_collaborator(self.project, user=member) + self.assertEqual(self.submit(self.project, self.link).status_code, 200) + row.role = "Changed" + with self.assertRaises(ValidationError): + row.save() + with self.assertRaises(ValidationError): + row.delete() + row.refresh_from_db() + self.assertNotEqual(row.role, "Changed") + + def test_recommendation_candidates_are_actionable(self): + from users.models import CustomUser + from projects.team_policy import actionable_users + + eligible = self.registered(self.program) + pending_user = self.registered(self.program) + self.invite(self.project, pending_user) + other, _ = self.team(self.program) + user_ids = set( + actionable_users(self.project, CustomUser.objects.all()).values_list( + "pk", flat=True + ) + ) + self.assertIn(eligible.pk, user_ids) + self.assertNotIn(pending_user.pk, user_ids) + self.assertNotIn(other.leader_id, user_ids) + self.assertNotIn(self.project.leader_id, user_ids) + self.assertNotIn(create_user().pk, user_ids) + + +@skipUnlessDBFeature("has_select_for_update") +class CompetitiveTeamConcurrencyTests(TeamPolicyFixtures, TransactionTestCase): + race = integrity.InviteConcurrencyTests.race + + def test_concurrent_invites_reserve_only_one_remaining_place(self): + project, link = self.team() + program = link.partner_program + program.legacy_team_max_size = 2 + program.save(update_fields=["legacy_team_max_size"]) + users = [self.registered(program), self.registered(program)] + statuses = self.race( + *[ + ( + project.leader, + "post", + "/invites/", + {"project": project.pk, "user": user.pk}, + ) + for user in users + ] + ) + self.assertEqual(sorted(statuses), [201, 409]) + self.assertEqual(len(team_policy.reserved_user_ids(project)), 2) + + def test_accept_into_two_teams_only_one_succeeds(self): + project, link = self.team() + other, _ = self.team(link.partner_program) + user = self.registered(link.partner_program) + first, second = self.invite(project, user), self.invite(other, user) + statuses = self.race( + (user, "post", f"/invites/{first.pk}/accept/", {}), + (user, "post", f"/invites/{second.pk}/accept/", {}), + ) + self.assertEqual(sorted(statuses), [200, 409]) + self.assertEqual(Collaborator.objects.filter(user=user).count(), 1) + + def test_submit_accept_share_final_roster(self): + project, link = self.team() + user = self.registered(link.partner_program) + pending = self.invite(project, user) + snapshots = [] + original = team_policy.validate_submission_team + + def capture(*args): + result = original(*args) + snapshots.append(result) + return result + + with patch.object(team_policy, "validate_submission_team", side_effect=capture): + statuses = self.race( + ( + project.leader, + "post", + f"/programs/partner-program-projects/{link.pk}/submit/", + {}, + ), + (user, "post", f"/invites/{pending.pk}/accept/", {}), + ) + self.assertEqual(statuses[0], 200) + self.assertIn(statuses[1], (200, 409)) + self.assertEqual(team_policy.accepted_user_ids(project), snapshots[-1]) + + def test_submit_remove_share_final_roster(self): + project, link = self.team() + member = self.registered(link.partner_program) + create_collaborator(project, user=member) + snapshots = [] + original = team_policy.validate_submission_team + + def capture(*args): + result = original(*args) + snapshots.append(result) + return result + + with patch.object(team_policy, "validate_submission_team", side_effect=capture): + statuses = self.race( + ( + project.leader, + "post", + f"/programs/partner-program-projects/{link.pk}/submit/", + {}, + ), + ( + project.leader, + "delete", + f"/projects/{project.pk}/collaborators/?id={member.pk}", + {}, + ), + ) + self.assertEqual(statuses[0], 200) + self.assertIn(statuses[1], (204, 409)) + self.assertEqual(team_policy.accepted_user_ids(project), snapshots[-1]) diff --git a/projects/tests/test_program_project_detail_access.py b/projects/tests/test_program_project_detail_access.py index a2cbd48d..cf384d98 100644 --- a/projects/tests/test_program_project_detail_access.py +++ b/projects/tests/test_program_project_detail_access.py @@ -41,7 +41,8 @@ def setUpTestData(cls): cls.superuser.is_superuser = True cls.superuser.save(update_fields=["is_superuser"]) cls.invited = create_user(prefix="detail-invited") - Invite.objects.create(project=cls.project, user=cls.invited) + # Исторический invite без регистрации: проверяем только read permissions. + Invite.objects.bulk_create([Invite(project=cls.project, user=cls.invited)]) cls.outsider = create_user(prefix="detail-outsider") def setUp(self): diff --git a/projects/views.py b/projects/views.py index 3c6c6f13..b354ec8d 100644 --- a/projects/views.py +++ b/projects/views.py @@ -18,7 +18,6 @@ from core.services import add_view from partner_programs.models import ( PartnerProgram, - PartnerProgramProject, PartnerProgramUserProfile, ) from projects.cover_reset import reset_project_cover @@ -64,6 +63,7 @@ ProjectListSerializer, ProjectSubscribersListSerializer, ResourceSerializer, + requested_program_link, ) from users.models import LikesOnProject from users.serializers import UserListSerializer @@ -90,6 +90,7 @@ def get_queryset(self) -> QuerySet[Project]: queryset = Project.objects.get_projects_for_list_view() return queryset + @transaction.atomic def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -100,7 +101,12 @@ def create(self, request, *args, **kwargs): try: partner_program_id = request.data.get("partner_program_id") - update_partner_program(partner_program_id, request.user, serializer.instance) + update_partner_program( + partner_program_id, + request.user, + serializer.instance, + program_link_id=requested_program_link(request), + ) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -164,11 +170,17 @@ def retrieve(self, request, *args, **kwargs): serializer = self.get_serializer(instance) return Response(serializer.data) + @transaction.atomic def put(self, request, pk, **kwargs): # fixme: add partner_program_id to docs try: partner_program_id = request.data.get("partner_program_id") - update_partner_program(partner_program_id, request.user, self.get_object()) + update_partner_program( + partner_program_id, + request.user, + self.get_object(), + program_link_id=requested_program_link(request), + ) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -182,11 +194,17 @@ def put(self, request, pk, **kwargs): check_related_fields_update(request.data, pk) return super(ProjectDetail, self).put(request, pk) + @transaction.atomic def patch(self, request, pk, **kwargs): # fixme: add partner_program_id to docs try: partner_program_id = request.data.get("partner_program_id") - update_partner_program(partner_program_id, request.user, self.get_object()) + update_partner_program( + partner_program_id, + request.user, + self.get_object(), + program_link_id=requested_program_link(request), + ) except PartnerProgram.DoesNotExist: return Response( {"detail": "Partner program with this id does not exist"}, @@ -223,7 +241,9 @@ class ProjectRecommendedUsers(generics.RetrieveAPIView): def get(self, request, pk, **kwargs): project = self.get_object() - recommended_users = get_recommended_users(project) + recommended_users = get_recommended_users( + project, program_link_id=requested_program_link(request, query=True) + ) serializer = self.get_serializer(recommended_users, many=True) return Response(status=status.HTTP_200_OK, data=serializer.data) @@ -290,7 +310,11 @@ def get_permissions(self): def post(self, request, pk: int): project = self.get_object() - team_service.reject_direct_add(project_id=project.pk, actor=request.user) + team_service.reject_direct_add( + project_id=project.pk, + actor=request.user, + program_link_id=requested_program_link(request), + ) def delete(self, request, pk: int): project = self.get_object() @@ -305,7 +329,10 @@ def delete(self, request, pk: int): status_code=422, ) team_service.remove_member( - project_id=project.pk, user_id=user_id, actor=request.user + project_id=project.pk, + user_id=user_id, + actor=request.user, + program_link_id=requested_program_link(request, query=True), ) return Response(status=204) @@ -454,7 +481,11 @@ class LeaveProject(generics.GenericAPIView): permission_classes = [IsAuthenticated] def delete(self, request, project_pk: int) -> Response: - team_service.leave_team(project_id=project_pk, actor=request.user) + team_service.leave_team( + project_id=project_pk, + actor=request.user, + program_link_id=requested_program_link(request, query=True), + ) return Response(status=204) @@ -475,7 +506,10 @@ class SwitchLeaderRole(generics.GenericAPIView): def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: project = self.get_object() team_service.switch_leader( - project_id=project.pk, user_id=user_to_leader_pk, actor=request.user + project_id=project.pk, + user_id=user_to_leader_pk, + actor=request.user, + program_link_id=requested_program_link(request), ) return Response(status=204) @@ -538,6 +572,12 @@ def post(self, request): partner_program = get_object_or_404(PartnerProgram, id=data["partner_program_id"]) with transaction.atomic(): + from projects.team_policy import lock_team + + original_project = lock_team( + original_project.pk, extra_program_ids=[partner_program.pk] + ).project + team_service.require_team_manager(original_project, request.user) new_project = Project.objects.create( name=original_project.name, description=original_project.description, @@ -560,8 +600,10 @@ def post(self, request): self._copy_collaborators(original_project, new_project) - program_link = PartnerProgramProject.objects.create( - partner_program=partner_program, project=new_project + program_link = team_service.bind_project_to_program( + project_id=new_project.pk, + program_id=partner_program.pk, + actor=request.user, ) return Response( diff --git a/users/serializers.py b/users/serializers.py index f457e42c..383befcd 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -944,6 +944,12 @@ class UserProjectListSerializer(serializers.ModelSerializer[Project]): views_count = serializers.SerializerMethodField(method_name="count_views") short_description = serializers.SerializerMethodField() partner_program = serializers.SerializerMethodField() + team_policy = serializers.SerializerMethodField() + + def get_team_policy(self, project): + from projects.team_policy import policy_snapshot + + return policy_snapshot(project) @classmethod def count_views(cls, project): @@ -994,6 +1000,7 @@ class Meta: "draft", "is_company", "partner_program", + "team_policy", ] read_only_fields = ["leader", "views_count", "is_company", "partner_program"] diff --git a/users/tests/test_project_list_program_contract.py b/users/tests/test_project_list_program_contract.py index 753ab750..d5bd98f4 100644 --- a/users/tests/test_project_list_program_contract.py +++ b/users/tests/test_project_list_program_contract.py @@ -46,7 +46,8 @@ def test_compact_contract_preserves_ids_and_authoritative_submission(self): link = link_project_to_program(self.project, create_partner_program()) for submitted in (False, True): link.submitted = submitted - link.save(update_fields=["submitted"]) + # Историческое состояние для проверки чтения/freeze, не новый submission. + type(link).objects.filter(pk=link.pk).update(submitted=link.submitted) for endpoint in self.endpoints: with self.subTest(submitted=submitted, endpoint=endpoint): self.assertEqual( @@ -70,12 +71,15 @@ def create_reversed_links(self): partner_program=earlier_program, submitted=False, ) - return PartnerProgramProject.objects.create( + # Историческая submitted-связь неконкурсной программы. + link = PartnerProgramProject( pk=10, project=self.project, partner_program=later_program, submitted=True, ) + PartnerProgramProject.objects.bulk_create([link]) + return link def test_list_and_detail_choose_minimum_link_pk_not_creation_or_program_order(self): selected = self.create_reversed_links() diff --git a/vacancy/response_services.py b/vacancy/response_services.py index 68f99dc5..31b19056 100644 --- a/vacancy/response_services.py +++ b/vacancy/response_services.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import NotFound, PermissionDenied from projects.models import Collaborator +from projects import team_policy, team_service from vacancy.mapping import CeleryEmailParams, MessageTypeEnum from vacancy.models import Vacancy, VacancyResponse from vacancy.tasks import send_email @@ -29,6 +30,8 @@ def _lock_vacancy_then_response( vacancy_id = ( VacancyResponse.objects.only("vacancy_id").get(pk=response_id).vacancy_id ) + project_id = Vacancy.objects.only("project_id").get(pk=vacancy_id).project_id + team_policy.lock_team(project_id) vacancy = ( Vacancy.objects.select_for_update() .select_related("project") @@ -51,6 +54,8 @@ def create_vacancy_response( """Создаёт отклик от request.user под блокировкой вакансии.""" try: + project_id = Vacancy.objects.only("project_id").get(pk=vacancy_id).project_id + team_policy.lock_team(project_id) vacancy = ( Vacancy.objects.select_for_update() .select_related("project") @@ -107,7 +112,9 @@ def _email_payload(response: VacancyResponse, message_type: str) -> CeleryEmailP @transaction.atomic -def accept_vacancy_response(response_id: int, *, actor) -> VacancyResponse: +def accept_vacancy_response( + response_id: int, *, actor, program_link_id=None +) -> VacancyResponse: """Принимает кандидата, закрывает вакансию и отклоняет остальные отклики.""" vacancy, response = _lock_vacancy_then_response(response_id) @@ -120,10 +127,13 @@ def accept_vacancy_response(response_id: int, *, actor) -> VacancyResponse: ).exists(): raise serializers.ValidationError("Пользователь уже состоит в команде проекта.") - Collaborator.objects.create( - project=vacancy.project, + team_service.add_member( + project_id=vacancy.project_id, user_id=response.user_id, role=vacancy.role, + actor=actor, + allow_staff=True, + program_link_id=program_link_id, ) response.is_approved = True response.save(update_fields=("is_approved", "datetime_updated")) diff --git a/vacancy/views.py b/vacancy/views.py index 7a46b8da..737a32bf 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -9,6 +9,8 @@ from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response +from projects.serializers import requested_program_link + from vacancy.filters import VacancyFilter from vacancy.models import Vacancy, VacancyResponse from vacancy.pagination import VacancyPagination @@ -211,7 +213,9 @@ def post(self, request, pk): vacancy_response = get_object_or_404(get_response_queryset(), pk=pk) if not can_manage_vacancy(request.user, vacancy_response.vacancy): return Response(status=status.HTTP_403_FORBIDDEN) - accept_vacancy_response(pk, actor=request.user) + accept_vacancy_response( + pk, actor=request.user, program_link_id=requested_program_link(request) + ) accepted = get_response_queryset().get(pk=pk) return Response( VacancyResponseManagerSerializer(