diff --git a/docs/project-subscriptions-workspace-api.md b/docs/project-subscriptions-workspace-api.md new file mode 100644 index 00000000..73440fce --- /dev/null +++ b/docs/project-subscriptions-workspace-api.md @@ -0,0 +1,69 @@ +# Project Subscriptions Workspace API + +## Назначение + +DEV-084.1 добавляет стабильный React workspace-контракт подписки на Project. +Новая модель не создаётся: источником данных остаётся существующая связь +`Project.subscribers`. Legacy API старого Angular-клиента сохраняется без +изменений. + +## Контракт + +Для авторизованного пользователя доступны методы: + +```http +GET /projects//workspace/subscription/ +POST /projects//workspace/subscription/ +DELETE /projects//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//workspace/` дополнительно возвращает: + +```json +{ + "is_subscribed": false, + "subscribers_count": 6 +} +``` + +Selector получает оба значения ORM-аннотациями `Exists` и `Count`. Serializer +не обращается к базе, не загружает пользователей и не создаёт N+1. + +## Совместимость и ограничения + +Без изменений продолжают работать: + +```http +POST /projects//subscribe/ +POST /projects//unsubscribe/ +GET /projects//subscribers/ +``` + +На этом этапе не добавляются React UI, email и внутренние уведомления, настройки +частоты, автоматическая подписка команды, список подписчиков и интеграция с +новостной лентой. diff --git a/projects/tests/test_project_workspace_subscriptions.py b/projects/tests/test_project_workspace_subscriptions.py new file mode 100644 index 00000000..66b6651e --- /dev/null +++ b/projects/tests/test_project_workspace_subscriptions.py @@ -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) diff --git a/projects/urls.py b/projects/urls.py index 076dd5ed..1357b7d2 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -43,6 +43,7 @@ ProjectCatalogView, ProjectWorkspaceCreateView, ProjectWorkspaceDetailView, + ProjectWorkspaceSubscriptionView, ) app_name = "projects" @@ -110,6 +111,11 @@ ProjectInvitationListCreateView.as_view(), name="workspace-invitations", ), + path( + "/workspace/subscription/", + ProjectWorkspaceSubscriptionView.as_view(), + name="workspace-subscription", + ), path( "/workspace/invitations/candidates/", ProjectInvitationCandidateSearchView.as_view(), diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py index ab855ea2..c87f6adf 100644 --- a/projects/workspace_selectors.py +++ b/projects/workspace_selectors.py @@ -1,4 +1,4 @@ -from django.db.models import Count, Prefetch, Q, QuerySet +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, QuerySet from core.models import SkillToObject from partner_programs.models import Application @@ -6,6 +6,24 @@ from vacancy.models import Vacancy +def annotate_workspace_subscription_state( + queryset: QuerySet[Project], *, user +) -> QuerySet[Project]: + """Добавляет состояние подписки без загрузки списка пользователей.""" + subscribers_field = Project._meta.get_field("subscribers") + through = Project.subscribers.through + current_subscription = through.objects.filter( + **{ + f"{subscribers_field.m2m_field_name()}_id": OuterRef("pk"), + f"{subscribers_field.m2m_reverse_field_name()}_id": user.pk, + } + ) + return queryset.annotate( + is_subscribed=Exists(current_subscription), + subscribers_count=Count("subscribers", distinct=True), + ) + + def _with_workspace_relations(queryset: QuerySet[Project], user) -> QuerySet[Project]: """Загружает роли и активности заранее, исключая N+1 в API проектов.""" current_user_collaborations = Collaborator.objects.filter(user=user) @@ -62,7 +80,10 @@ def get_workspace_project_queryset(*, user): .prefetch_related(Prefetch("required_skills", queryset=required_skills)) .order_by("-datetime_created", "-id") ) - queryset = _with_workspace_relations(Project.objects.all(), user) + queryset = annotate_workspace_subscription_state( + _with_workspace_relations(Project.objects.all(), user), + user=user, + ) return queryset.prefetch_related( Prefetch( "collaborator_set", @@ -81,3 +102,9 @@ def filter_workspace_visible_projects(queryset: QuerySet[Project], *, user): return queryset.filter( Q(draft=False, is_public=True) | Q(leader=user) | Q(collaborator__user=user) ).distinct() + + +def get_workspace_subscription_queryset(*, user) -> QuerySet[Project]: + """Возвращает доступные проекты с read-only состоянием текущей подписки.""" + queryset = annotate_workspace_subscription_state(Project.objects.all(), user=user) + return filter_workspace_visible_projects(queryset, user=user) diff --git a/projects/workspace_serializers.py b/projects/workspace_serializers.py index 467a85b2..b1205118 100644 --- a/projects/workspace_serializers.py +++ b/projects/workspace_serializers.py @@ -56,6 +56,27 @@ class ProjectWorkspaceCollaboratorSerializer(serializers.Serializer): specialization = serializers.CharField(allow_blank=True, allow_null=True) +class ProjectSubscriptionStateSerializer(serializers.Serializer): + """Стабильный workspace-контракт без персональных данных подписчиков.""" + + is_subscribed = serializers.BooleanField(read_only=True) + subscribers_count = serializers.IntegerField(read_only=True, min_value=0) + + +class ProjectSubscriptionActionSerializer(serializers.Serializer): + """Разрешает только обязательный пустой payload действия подписки.""" + + def validate(self, attrs): + if self.initial_data: + raise serializers.ValidationError( + { + field: "Это поле нельзя передавать для данного действия." + for field in sorted(self.initial_data) + } + ) + return attrs + + class ProjectActivitySerializer(serializers.Serializer): id = serializers.IntegerField(source="program_id") name = serializers.CharField(source="program.name") @@ -115,6 +136,8 @@ class ProjectWorkspaceDetailSerializer(ProjectWorkspaceListSerializer): links = serializers.SerializerMethodField() industry = serializers.SerializerMethodField() vacancies = ProjectWorkspaceVacancySerializer(many=True, read_only=True) + is_subscribed = serializers.BooleanField(read_only=True) + subscribers_count = serializers.IntegerField(read_only=True, min_value=0) class Meta(ProjectWorkspaceListSerializer.Meta): fields = ProjectWorkspaceListSerializer.Meta.fields + ( @@ -132,6 +155,8 @@ class Meta(ProjectWorkspaceListSerializer.Meta): "industry", "datetime_created", "vacancies", + "is_subscribed", + "subscribers_count", ) def get_collaborators(self, project): diff --git a/projects/workspace_subscription_services.py b/projects/workspace_subscription_services.py new file mode 100644 index 00000000..a7193f2b --- /dev/null +++ b/projects/workspace_subscription_services.py @@ -0,0 +1,43 @@ +from django.db import transaction + +from projects.models import Project +from projects.workspace_selectors import ( + filter_workspace_visible_projects, + get_workspace_subscription_queryset, +) + + +@transaction.atomic +def set_workspace_project_subscription( + *, project_id: int, user, is_subscribed: bool +) -> Project: + """Идемпотентно меняет подписку и возвращает актуальное состояние. + + Сначала проверяем workspace-видимость, затем блокируем только строку Project. + Все новые mutation endpoints используют одинаковый порядок блокировки, поэтому + параллельные POST/DELETE не расходятся по count и не блокируют nullable JOIN. + """ + visible_project_id = ( + filter_workspace_visible_projects(Project.objects.all(), user=user) + .filter(pk=project_id) + .values_list("pk", flat=True) + .first() + ) + if visible_project_id is None: + raise Project.DoesNotExist + + project = Project.objects.select_for_update().get(pk=visible_project_id) + # Видимость проверяется повторно после блокировки: проект мог стать private/draft + # между первым чтением и началом изменения M2M-связи. + if not filter_workspace_visible_projects( + Project.objects.filter(pk=project.pk), + user=user, + ).exists(): + raise Project.DoesNotExist + + if is_subscribed: + project.subscribers.add(user) + else: + project.subscribers.remove(user) + + return get_workspace_subscription_queryset(user=user).get(pk=project.pk) diff --git a/projects/workspace_views.py b/projects/workspace_views.py index feffbd1d..777868b1 100644 --- a/projects/workspace_views.py +++ b/projects/workspace_views.py @@ -1,23 +1,30 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics, status -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from projects.models import Project from projects.pagination import ProjectsPagination from projects.workspace_selectors import ( filter_workspace_visible_projects, get_project_catalog_queryset, get_user_projects_queryset, get_workspace_project_queryset, + get_workspace_subscription_queryset, ) from projects.workspace_serializers import ( + ProjectSubscriptionActionSerializer, + ProjectSubscriptionStateSerializer, ProjectWorkspaceCreateSerializer, ProjectWorkspaceDetailSerializer, ProjectWorkspaceListSerializer, ProjectWorkspaceUpdateSerializer, ) +from projects.workspace_subscription_services import ( + set_workspace_project_subscription, +) def _parse_industry_id(raw_value): @@ -128,3 +135,48 @@ def patch(self, request, project_id): context={"request": request}, ) return Response(response_serializer.data, status=status.HTTP_200_OK) + + +class ProjectWorkspaceSubscriptionView(APIView): + """Возвращает и идемпотентно меняет подписку текущего пользователя.""" + + permission_classes = [IsAuthenticated] + + def get_object(self, request, project_id): + queryset = get_workspace_subscription_queryset(user=request.user) + project = queryset.filter(pk=project_id).first() + if project is None: + raise NotFound("Проект не найден.") + return project + + def get(self, request, project_id): + project = self.get_object(request, project_id) + return Response(ProjectSubscriptionStateSerializer(project).data) + + def update_subscription(self, request, project_id, *, is_subscribed): + """Проверяет пустой payload и преобразует отсутствие доступа в safe 404.""" + serializer = ProjectSubscriptionActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + project = set_workspace_project_subscription( + project_id=project_id, + user=request.user, + is_subscribed=is_subscribed, + ) + except Project.DoesNotExist as exc: + raise NotFound("Проект не найден.") from exc + return Response(ProjectSubscriptionStateSerializer(project).data) + + def post(self, request, project_id): + return self.update_subscription( + request, + project_id, + is_subscribed=True, + ) + + def delete(self, request, project_id): + return self.update_subscription( + request, + project_id, + is_subscribed=False, + )