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
5 changes: 5 additions & 0 deletions docs/modules/invites.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Invites отвечает за приглашения пользователей

Модуль рабочий и подключен в публичный API через `/invites/`.

Для React workspace DEV-079.1 добавлен отдельный project-scoped контракт поверх
той же модели `Invite`. Он описан в
[`docs/project-invitations-workspace-api.md`](../project-invitations-workspace-api.md)
и не меняет URL старого Angular-клиента.

Приглашения доступны приглашенному пользователю, лидеру проекта и
staff/superuser. Изменять или удалять приглашение может лидер проекта. Принять
или отклонить приглашение может приглашенный пользователь.
Expand Down
179 changes: 179 additions & 0 deletions docs/project-invitations-workspace-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Project Invitations Workspace API

## Назначение

DEV-079.1 добавляет безопасный backend-сценарий приглашения зарегистрированного
пользователя в рабочее пространство `Project`. После принятия пользователь
становится `Collaborator` этого проекта. `TeamMember`, участник Application,
подписчик проекта и `Collaborator` остаются разными сущностями.

React-интерфейс будет добавлен отдельным этапом DEV-079.2. В этом PR не
добавляются email-приглашения, токены, сроки действия и приглашения
незарегистрированных пользователей.

## Аудит legacy-сценария

Существующая модель `invites.Invite` уже хранит связь `Project` и
зарегистрированного `CustomUser`, роль, специализацию, сообщение и tri-state
`is_accepted`. Angular передает числовой user id, извлеченный из ссылки на
профиль, в `POST /invites/`, получает активные приглашения через
`GET /invites/`, принимает и отклоняет их отдельными action endpoints, а
`DELETE /invites/<id>/` использует как отзыв.

Legacy-контракт `/invites/` сохранен. Новый workspace API использует ту же
сущность, но не требует от React знания legacy-полей `project`, `user` и
`is_accepted`.

## Lifecycle и миграция

В `Invite` добавлены:

- `invited_by` — зарегистрированный отправитель;
- `is_revoked` — отзыв без физического удаления;
- `resolved_at` — время принятия, отклонения или отзыва.

Публичный `status` вычисляется без удаления legacy `is_accepted`:

- `pending` — `is_accepted=null`, `is_revoked=false`;
- `accepted` — `is_accepted=true`;
- `declined` — `is_accepted=false`;
- `revoked` — `is_revoked=true`.

Допустимые переходы:

```text
pending -> accepted
pending -> declined
pending -> revoked
```

Повторный переход завершенного приглашения возвращает `409`. Миграция заполняет
`invited_by` текущим лидером проекта, переносит дату обработки legacy-записей и,
если в старых данных есть несколько pending-записей одной пары Project/User,
оставляет активной только новейшую. Остальные сохраняются как `revoked`.

На уровне БД действуют:

- partial unique constraint для одной pending-записи на `project + user`;
- check constraint, запрещающий одновременно `is_revoked=true` и принятое или
отклоненное значение `is_accepted`;
- индексы списков по пользователю/проекту и lifecycle-полям.

## API

Все endpoints требуют аутентификацию.

### Список и создание

```http
GET /projects/<project_id>/workspace/invitations/
POST /projects/<project_id>/workspace/invitations/
```

Список содержит историю только указанного проекта. Доступ имеют лидер проекта,
staff и superuser.

Payload создания:

```json
{
"recipient_id": 42,
"role": "Разработчик",
"specialization": "Backend",
"message": "Присоединяйтесь к проекту"
}
```

Обязателен только `recipient_id`. Project берется из URL, отправитель — из
аутентифицированного пользователя. Неизвестные и read-only поля отклоняются.

### Входящие приглашения

```http
GET /projects/workspace/invitations/incoming/
```

Возвращает только историю текущего получателя; pending-приглашения идут первыми.

### Решение и отзыв

```http
POST /projects/workspace/invitations/<invitation_id>/accept/
POST /projects/workspace/invitations/<invitation_id>/decline/
POST /projects/<project_id>/workspace/invitations/<invitation_id>/revoke/
```

Action payload должен быть пустым. Принять или отклонить приглашение может
только получатель. Отозвать pending-приглашение может лидер соответствующего
Project, staff или superuser.

Коды ответа: `201` для создания, `200` для списков и успешных переходов, `400`
для невалидных полей или получателя, `403` для видимого Project без права
управления, безопасный `404` для скрытого Project/чужого приглашения и `409` для
активного дубля либо повторного перехода статуса.

Пример ответа:

```json
{
"id": 7,
"project": {
"id": 10,
"name": "Проект",
"draft": true,
"is_public": false
},
"sender": {
"id": 1,
"first_name": "Анна",
"last_name": "Иванова",
"avatar": null
},
"recipient": {
"id": 42,
"first_name": "Иван",
"last_name": "Петров",
"avatar": null
},
"status": "pending",
"role": "Разработчик",
"specialization": "Backend",
"message": "Присоединяйтесь к проекту",
"created_at": "2026-08-06T12:00:00Z",
"processed_at": null,
"updated_at": "2026-08-06T12:00:00Z"
}
```

## Права и ограничения

| Действие | Лидер | Collaborator | Получатель | Посторонний | Staff |
|---|---:|---:|---:|---:|---:|
| Список проекта | Да | Нет | Нет | Нет | Да |
| Создание | Да | Нет | Нет | Нет | Да |
| Входящие | Только свои | Только свои | Только свои | Только свои | Только свои |
| Accept/decline | Только если получатель | Только если получатель | Да | Нет | Только если получатель |
| Revoke | Да | Нет | Нет | Нет | Да |

Нельзя пригласить лидера, существующего `Collaborator`, неактивного или
несуществующего пользователя. Для legacy Project, напрямую связанного с
`PartnerProgramProject`, получатель должен быть участником этой программы — это
повторяет действующий invariant `Collaborator.clean()`.

Приватный Project скрывается от постороннего через `404`; пользователь, который
видит Project, но не управляет им, получает `403`. Ответы не содержат email,
телефон, анкету Application или данные Submission.

Создание, принятие, отклонение и отзыв выполняются в `transaction.atomic` с
блокировками Project и Invite. Принятие и создание `Collaborator` — одна
транзакция. Partial unique constraint закрывает гонку двух создающих запросов.

## Ограничения DEV-079.1

- приглашаются только существующие активные пользователи по `recipient_id`;
- отдельный безопасный поиск кандидатов не добавлен;
- email, уведомления, invite links и expiration отсутствуют;
- React UI относится к DEV-079.2;
- Angular продолжает использовать legacy `/invites/`;
- `TeamInvite`, `TeamMember`, Application, Submission, подписки, цели и
достижения не изменяются.
3 changes: 3 additions & 0 deletions invites/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ class InviteAdmin(admin.ModelAdmin):
fields = [
"project",
"user",
"invited_by",
"motivational_letter",
"role",
"specialization",
"is_accepted",
"is_revoked",
"resolved_at",
]
13 changes: 12 additions & 1 deletion invites/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,15 @@

class InviteManager(Manager):
def get_invite_for_list_view(self):
return self.get_queryset().select_related("project", "project__leader", "user")
return self.get_queryset().select_related(
"project",
"project__leader",
"user",
"invited_by",
)

def pending(self):
return self.get_queryset().filter(
is_accepted__isnull=True,
is_revoked=False,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Generated by Django 4.2.11 on 2026-08-06 13:36

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


def populate_invitation_lifecycle(apps, schema_editor):
"""Сохраняет legacy-состояния и устраняет активные дубли до constraint."""
Invite = apps.get_model("invites", "Invite")
database = schema_editor.connection.alias
seen_pending = set()
changed = []

invitations = (
Invite.objects.using(database)
.select_related("project")
.all()
.order_by("project_id", "user_id", "-datetime_created", "-id")
)
for invitation in invitations.iterator():
invitation.invited_by_id = invitation.project.leader_id
if invitation.is_accepted is not None:
invitation.resolved_at = invitation.datetime_updated
else:
key = (invitation.project_id, invitation.user_id)
if key in seen_pending:
# Новейшее pending-приглашение остается активным, предыдущие
# сохраняются в истории как отозванные.
invitation.is_revoked = True
invitation.resolved_at = invitation.datetime_updated
else:
seen_pending.add(key)
changed.append(invitation)

if changed:
Invite.objects.using(database).bulk_update(
changed,
["invited_by", "is_revoked", "resolved_at"],
batch_size=500,
)


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("invites", "0002_invite_specialization"),
]

operations = [
migrations.AddField(
model_name="invite",
name="invited_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="sent_project_invites",
to=settings.AUTH_USER_MODEL,
verbose_name="Кем приглашен",
),
),
migrations.AddField(
model_name="invite",
name="is_revoked",
field=models.BooleanField(default=False, verbose_name="Отозвано"),
),
migrations.AddField(
model_name="invite",
name="resolved_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Дата обработки"
),
),
migrations.RunPython(
populate_invitation_lifecycle,
migrations.RunPython.noop,
),
migrations.AddIndex(
model_name="invite",
index=models.Index(
fields=["user", "is_accepted", "is_revoked", "datetime_created"],
name="invite_user_state_idx",
),
),
migrations.AddIndex(
model_name="invite",
index=models.Index(
fields=["project", "is_accepted", "is_revoked", "datetime_created"],
name="invite_project_state_idx",
),
),
migrations.AddConstraint(
model_name="invite",
constraint=models.UniqueConstraint(
condition=models.Q(("is_accepted__isnull", True), ("is_revoked", False)),
fields=("project", "user"),
name="uniq_pending_project_invite",
),
),
migrations.AddConstraint(
model_name="invite",
constraint=models.CheckConstraint(
check=models.Q(
("is_revoked", False), ("is_accepted__isnull", True), _connector="OR"
),
name="invite_revoked_unaccepted",
),
),
]
Loading
Loading