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
69 changes: 69 additions & 0 deletions docs/project-subscriptions-workspace-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Project Subscriptions Workspace API

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

DEV-084.1 добавляет стабильный React workspace-контракт подписки на Project.
Новая модель не создаётся: источником данных остаётся существующая связь
`Project.subscribers`. Legacy API старого Angular-клиента сохраняется без
изменений.

## Контракт

Для авторизованного пользователя доступны методы:

```http
GET /projects/<project_id>/workspace/subscription/
POST /projects/<project_id>/workspace/subscription/
DELETE /projects/<project_id>/workspace/subscription/
```

`POST` и `DELETE` принимают только пустой JSON-объект `{}`. Ответ всех методов:

```json
{
"is_subscribed": true,
"subscribers_count": 7
}
```

Повторный `POST` сохраняет подписку, а повторный `DELETE` сохраняет отсутствие
подписки. Оба действия идемпотентны и возвращают фактическое текущее количество.

## Видимость и права

- опубликованный публичный Project доступен любому авторизованному пользователю;
- private или draft Project доступен руководителю, collaborator, staff и
superuser;
- отсутствующий или недоступный Project скрывается одинаковым ответом `404`;
- клиент не может передать пользователя, Project или состояние через payload.

Список подписчиков, email, телефон и другие данные профилей не возвращаются.
Подписка не создаёт Collaborator, Invite, News или другие доменные сущности.

## Workspace detail

`GET /projects/<project_id>/workspace/` дополнительно возвращает:

```json
{
"is_subscribed": false,
"subscribers_count": 6
}
```

Selector получает оба значения ORM-аннотациями `Exists` и `Count`. Serializer
не обращается к базе, не загружает пользователей и не создаёт N+1.

## Совместимость и ограничения

Без изменений продолжают работать:

```http
POST /projects/<project_id>/subscribe/
POST /projects/<project_id>/unsubscribe/
GET /projects/<project_id>/subscribers/
```

На этом этапе не добавляются React UI, email и внутренние уведомления, настройки
частоты, автоматическая подписка команды, список подписчиков и интеграция с
новостной лентой.
259 changes: 259 additions & 0 deletions projects/tests/test_project_workspace_subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from rest_framework.test import APIClient

from invites.models import Invite
from news.models import News
from projects.models import Collaborator
from projects.tests.helpers import (
create_collaborator,
create_project,
create_staff_user,
create_user,
)
from projects.workspace_selectors import get_workspace_subscription_queryset
from projects.workspace_serializers import ProjectSubscriptionStateSerializer


class ProjectWorkspaceSubscriptionAPITests(TestCase):
def setUp(self):
self.client = APIClient()
self.leader = create_user(prefix="subscription-leader")
self.collaborator = create_user(prefix="subscription-collaborator")
self.outsider = create_user(prefix="subscription-outsider")
self.staff = create_user(prefix="subscription-staff")
self.staff.is_staff = True
self.staff.save(update_fields=["is_staff"])
self.superuser = create_staff_user(prefix="subscription-superuser")
self.public_project = create_project(
leader=self.leader,
draft=False,
is_public=True,
)
self.private_project = create_project(
leader=self.leader,
draft=True,
is_public=False,
)

def authenticate(self, user):
self.client.force_authenticate(user=user)

def url(self, project=None):
project = project or self.public_project
return f"/projects/{project.pk}/workspace/subscription/"

def test_all_methods_require_authentication(self):
self.assertEqual(self.client.get(self.url()).status_code, 401)
self.assertEqual(
self.client.post(self.url(), {}, format="json").status_code,
401,
)
self.assertEqual(
self.client.delete(self.url(), {}, format="json").status_code,
401,
)

def test_get_returns_unsubscribed_state_for_current_user(self):
self.public_project.subscribers.add(self.leader)
self.authenticate(self.outsider)

response = self.client.get(self.url())

self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{"is_subscribed": False, "subscribers_count": 1},
)

def test_subscribe_is_idempotent_and_updates_count(self):
other_subscriber = create_user(prefix="subscription-other")
self.public_project.subscribers.add(other_subscriber)
self.authenticate(self.outsider)

first = self.client.post(self.url(), {}, format="json")
second = self.client.post(self.url(), {}, format="json")

self.assertEqual(first.status_code, 200)
self.assertEqual(
first.data,
{"is_subscribed": True, "subscribers_count": 2},
)
self.assertEqual(second.status_code, 200)
self.assertEqual(second.data, first.data)
self.assertEqual(
self.public_project.subscribers.filter(pk=self.outsider.pk).count(),
1,
)

def test_unsubscribe_is_idempotent_and_updates_count(self):
self.public_project.subscribers.add(self.outsider, self.leader)
self.authenticate(self.outsider)

first = self.client.delete(self.url(), {}, format="json")
second = self.client.delete(self.url(), {}, format="json")

self.assertEqual(first.status_code, 200)
self.assertEqual(
first.data,
{"is_subscribed": False, "subscribers_count": 1},
)
self.assertEqual(second.status_code, 200)
self.assertEqual(second.data, first.data)
self.assertFalse(
self.public_project.subscribers.filter(pk=self.outsider.pk).exists()
)

def test_multiple_users_have_independent_state(self):
second_user = create_user(prefix="subscription-second")
self.public_project.subscribers.add(self.outsider)

self.authenticate(self.outsider)
subscribed = self.client.get(self.url())
self.authenticate(second_user)
unsubscribed = self.client.get(self.url())

self.assertTrue(subscribed.data["is_subscribed"])
self.assertFalse(unsubscribed.data["is_subscribed"])
self.assertEqual(subscribed.data["subscribers_count"], 1)
self.assertEqual(unsubscribed.data["subscribers_count"], 1)

def test_workspace_detail_contains_annotated_subscription_state(self):
other_subscriber = create_user(prefix="subscription-detail-other")
self.public_project.subscribers.add(other_subscriber)
self.authenticate(self.outsider)

response = self.client.get(f"/projects/{self.public_project.pk}/workspace/")

self.assertEqual(response.status_code, 200)
self.assertFalse(response.data["is_subscribed"])
self.assertEqual(response.data["subscribers_count"], 1)
self.public_project.subscribers.add(self.outsider)
response = self.client.get(f"/projects/{self.public_project.pk}/workspace/")
self.assertTrue(response.data["is_subscribed"])
self.assertEqual(response.data["subscribers_count"], 2)

def test_private_project_is_visible_to_workspace_members_and_admins(self):
create_collaborator(self.private_project, user=self.collaborator)

for user in (self.leader, self.collaborator, self.staff, self.superuser):
with self.subTest(user=user.email):
self.authenticate(user)
response = self.client.get(self.url(self.private_project))
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{"is_subscribed": False, "subscribers_count": 0},
)
subscribed = self.client.post(
self.url(self.private_project),
{},
format="json",
)
self.assertEqual(subscribed.status_code, 200)
self.assertTrue(subscribed.data["is_subscribed"])
removed = self.client.delete(
self.url(self.private_project),
{},
format="json",
)
self.assertEqual(removed.status_code, 200)
self.assertEqual(
removed.data,
{"is_subscribed": False, "subscribers_count": 0},
)

def test_private_project_is_hidden_from_outsider(self):
self.authenticate(self.outsider)

for method in ("get", "post", "delete"):
with self.subTest(method=method):
response = getattr(self.client, method)(
self.url(self.private_project),
{},
format="json",
)
self.assertEqual(response.status_code, 404)

def test_missing_project_returns_safe_not_found(self):
self.authenticate(self.outsider)

url = "/projects/999999999/workspace/subscription/"
for method in ("get", "post", "delete"):
with self.subTest(method=method):
response = getattr(self.client, method)(url, {}, format="json")
self.assertEqual(response.status_code, 404)
self.assertNotIn(str(self.outsider.pk), str(response.data))
self.assertNotIn("999999999", str(response.data))

def test_invalid_action_payload_is_rejected_without_mutation(self):
self.authenticate(self.outsider)

post_response = self.client.post(
self.url(), {"user_id": self.leader.pk}, format="json"
)
delete_response = self.client.delete(
self.url(), {"project_id": self.public_project.pk}, format="json"
)

self.assertEqual(post_response.status_code, 400)
self.assertEqual(delete_response.status_code, 400)
self.assertFalse(
self.public_project.subscribers.filter(pk=self.outsider.pk).exists()
)

def test_response_never_contains_subscriber_profiles(self):
self.public_project.subscribers.add(self.leader)
self.authenticate(self.outsider)

response = self.client.get(self.url())
detail = self.client.get(f"/projects/{self.public_project.pk}/workspace/")

self.assertEqual(set(response.data), {"is_subscribed", "subscribers_count"})
self.assertNotIn("subscribers", detail.data)
self.assertNotIn(self.leader.email, str(response.data))
self.assertNotIn(self.leader.email, str(detail.data))

def test_subscription_does_not_create_related_domain_objects(self):
self.authenticate(self.outsider)
counts_before = {
"collaborators": Collaborator.objects.count(),
"invites": Invite.objects.count(),
"news": News.objects.count(),
}

response = self.client.post(self.url(), {}, format="json")

self.assertEqual(response.status_code, 200)
self.assertEqual(Collaborator.objects.count(), counts_before["collaborators"])
self.assertEqual(Invite.objects.count(), counts_before["invites"])
self.assertEqual(News.objects.count(), counts_before["news"])

def test_state_serializer_performs_no_database_queries(self):
self.authenticate(self.outsider)
project = get_workspace_subscription_queryset(user=self.outsider).get(
pk=self.public_project.pk
)

with self.assertNumQueries(0):
data = ProjectSubscriptionStateSerializer(project).data

self.assertEqual(
data,
{"is_subscribed": False, "subscribers_count": 0},
)

def test_state_endpoint_query_budget_does_not_depend_on_subscriber_count(self):
subscribers = [
create_user(prefix=f"subscription-budget-{index}") for index in range(8)
]
self.public_project.subscribers.add(*subscribers)
self.authenticate(self.outsider)

with CaptureQueriesContext(connection) as queries:
response = self.client.get(self.url())

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["subscribers_count"], len(subscribers))
self.assertLessEqual(len(queries), 2)
6 changes: 6 additions & 0 deletions projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ProjectCatalogView,
ProjectWorkspaceCreateView,
ProjectWorkspaceDetailView,
ProjectWorkspaceSubscriptionView,
)

app_name = "projects"
Expand Down Expand Up @@ -110,6 +111,11 @@
ProjectInvitationListCreateView.as_view(),
name="workspace-invitations",
),
path(
"<int:project_id>/workspace/subscription/",
ProjectWorkspaceSubscriptionView.as_view(),
name="workspace-subscription",
),
path(
"<int:project_id>/workspace/invitations/candidates/",
ProjectInvitationCandidateSearchView.as_view(),
Expand Down
Loading
Loading