Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/invite-integrity-api.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions invites/migrations/0003_legacy_pending_invite_constraint.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
11 changes: 8 additions & 3 deletions invites/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
]
62 changes: 18 additions & 44 deletions invites/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,56 +32,24 @@ 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]):
user = UserDetailSerializer(many=False, read_only=True)
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:
Expand All @@ -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,
)
18 changes: 10 additions & 8 deletions invites/tests/test_invite_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down
Loading
Loading