diff --git a/docs/subscribed-projects-api.md b/docs/subscribed-projects-api.md new file mode 100644 index 00000000..e6cd3490 --- /dev/null +++ b/docs/subscribed-projects-api.md @@ -0,0 +1,95 @@ +# Subscribed Projects API + +## Назначение + +`GET /projects/subscribed/` возвращает карточки проектов, на обновления которых +подписан текущий пользователь. Endpoint предназначен для будущей React-вкладки +«Мои подписки» и требует авторизации. + +## Источник данных и видимость + +Список использует существующую M2M-связь `Project.subscribers`; отдельная модель +подписок не создаётся. Queryset формируется в базе данных как пересечение: + +1. проектов, где текущий пользователь присутствует в `subscribers`; +2. проектов, доступных ему по общим workspace-правилам. + +Опубликованный публичный проект доступен любому авторизованному пользователю. +Private или draft видны руководителю, collaborator, staff и superuser. Если +пользователь потерял доступ к private/draft проекту, карточка исчезает из выдачи, +но историческая M2M-связь подписки не удаляется. + +## Запрос + +```http +GET /projects/subscribed/ +GET /projects/subscribed/?page=2 +GET /projects/subscribed/?search=лаборатория +GET /projects/subscribed/?page=2&search=лаборатория +``` + +`search` применяется только к подпискам текущего пользователя, ищет по названию +без учёта регистра и игнорирует пробелы по краям. Пустое значение эквивалентно +отсутствию поиска. Персональные данные пользователей в поиск не входят. + +## Пагинация и сортировка + +Размер страницы равен 10, а ответ сохраняет общий формат project lists: + +```json +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 42, + "name": "Лаборатория городской среды", + "short_description": "Краткое описание", + "image_address": null, + "cover_image_address": null, + "draft": false, + "is_public": true, + "current_user_role": null, + "can_edit": false, + "can_use_in_application": false, + "activities": [], + "datetime_updated": "2026-08-11T09:30:00Z" + } + ] +} +``` + +Несуществующая или некорректная страница возвращает безопасный `404`. Проекты +сортируются по `-datetime_updated`, затем по `-id`, поэтому порядок остаётся +стабильным при одинаковом времени обновления. + +## Контракт карточки и запросы + +Endpoint переиспользует `ProjectWorkspaceListSerializer`, применяемый в +`/projects/my/` и `/projects/catalog/`. Он не возвращает подписчиков, email, +телефоны, даты рождения, приглашения или другие закрытые данные. + +Роли текущего пользователя и связанные активности загружаются заранее. Serializer +не обращается к базе, а фильтрация видимости не выполняется отдельно для каждой +карточки. GET не изменяет Project, подписки, collaborators, приглашения и новости. + +## Совместимость + +Workspace endpoints состояния и изменения подписки сохраняются без изменений: + +```http +GET /projects//workspace/subscription/ +POST /projects//workspace/subscription/ +DELETE /projects//workspace/subscription/ +``` + +Legacy Angular endpoints `subscribe`, `unsubscribe` и `subscribers` также не +изменяются. После успешной workspace-подписки доступный проект появляется в новом +списке, после отписки — исчезает. + +## Ограничения этапа + +DEV‑084.3 не добавляет React-интерфейс, список пользователей-подписчиков, +уведомления, автоматическую подписку, очистку потерявших доступ связей или новую +модель данных. Новостная лента, зависимости и deploy-конфигурация не меняются. diff --git a/projects/pagination.py b/projects/pagination.py index ab4caeeb..e3ff1b76 100644 --- a/projects/pagination.py +++ b/projects/pagination.py @@ -13,3 +13,9 @@ class ProjectsPagination(pagination.LimitOffsetPagination): default_limit = 10 limit_query_param = "limit" offset_query_param = "offset" + + +class SubscribedProjectsPagination(pagination.PageNumberPagination): + """Сохраняет размер и структуру списков проектов с параметром `page`.""" + + page_size = ProjectsPagination.default_limit diff --git a/projects/tests/test_subscribed_projects_api.py b/projects/tests/test_subscribed_projects_api.py new file mode 100644 index 00000000..e25bd3ec --- /dev/null +++ b/projects/tests/test_subscribed_projects_api.py @@ -0,0 +1,416 @@ +from datetime import timedelta + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from rest_framework.test import APIClient, APIRequestFactory + +from invites.models import Invite +from news.models import News +from partner_programs.models import Application +from projects.models import Collaborator, Project +from projects.tests.helpers import ( + create_collaborator, + create_partner_program, + create_project, + create_staff_user, + create_user, +) +from projects.workspace_selectors import get_subscribed_projects_queryset +from projects.workspace_serializers import ProjectWorkspaceListSerializer + + +class SubscribedProjectsAPITests(TestCase): + url = "/projects/subscribed/" + + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="subscribed-projects-user") + self.other_user = create_user(prefix="subscribed-projects-other") + self.leader = create_user(prefix="subscribed-projects-leader") + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.user) + + def subscribe(self, project, user=None): + project.subscribers.add(user or self.user) + + def create_public_project(self, **kwargs): + return create_project( + leader=kwargs.pop("leader", self.leader), + draft=False, + is_public=True, + **kwargs, + ) + + def response_ids(self, response): + return [item["id"] for item in response.data["results"]] + + def test_endpoint_requires_authentication(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 401) + + def test_user_without_subscriptions_gets_empty_paginated_response(self): + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"count": 0, "next": None, "previous": None, "results": []}, + ) + + def test_only_current_users_public_subscriptions_are_returned(self): + subscribed = self.create_public_project(name="Current subscription") + other_subscription = self.create_public_project(name="Other subscription") + self.subscribe(subscribed) + self.subscribe(other_subscription, self.other_user) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), [subscribed.pk]) + + def test_multiple_subscriptions_are_unique(self): + first = self.create_public_project(name="First subscription") + second = self.create_public_project(name="Second subscription") + self.subscribe(first) + self.subscribe(first) + self.subscribe(second) + for project in (first, second): + for index in range(2): + Application.objects.create( + program=create_partner_program(name=f"Program {project.pk}-{index}"), + user=self.user, + created_by=self.user, + project=project, + ) + self.authenticate() + + response = self.client.get(self.url) + ids = self.response_ids(response) + + self.assertEqual(response.status_code, 200) + self.assertEqual(set(ids), {first.pk, second.pk}) + self.assertEqual(len(ids), 2) + + def test_workspace_subscribe_adds_and_unsubscribe_removes_project(self): + project = self.create_public_project() + self.authenticate() + + subscribed = self.client.post( + f"/projects/{project.pk}/workspace/subscription/", + {}, + format="json", + ) + after_subscribe = self.client.get(self.url) + unsubscribed = self.client.delete( + f"/projects/{project.pk}/workspace/subscription/", + {}, + format="json", + ) + after_unsubscribe = self.client.get(self.url) + + self.assertEqual(subscribed.status_code, 200) + self.assertEqual(self.response_ids(after_subscribe), [project.pk]) + self.assertEqual(unsubscribed.status_code, 200) + self.assertEqual(self.response_ids(after_unsubscribe), []) + + def test_foreign_private_and_draft_subscriptions_are_hidden(self): + hidden_projects = ( + create_project( + leader=self.leader, + name="Private project", + draft=False, + is_public=False, + ), + create_project( + leader=self.leader, + name="Draft project", + draft=True, + is_public=True, + ), + ) + for project in hidden_projects: + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), []) + + def test_leader_sees_subscribed_private_and_draft_projects(self): + projects = ( + create_project( + leader=self.user, + draft=False, + is_public=False, + ), + create_project( + leader=self.user, + draft=True, + is_public=True, + ), + ) + for project in projects: + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(set(self.response_ids(response)), {item.pk for item in projects}) + + def test_collaborator_sees_subscribed_private_and_draft_projects(self): + projects = ( + create_project( + leader=self.leader, + draft=False, + is_public=False, + ), + create_project( + leader=self.leader, + draft=True, + is_public=True, + ), + ) + for project in projects: + create_collaborator(project, user=self.user) + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual(set(self.response_ids(response)), {item.pk for item in projects}) + self.assertTrue( + all( + item["current_user_role"] == "collaborator" + for item in response.data["results"] + ) + ) + + def test_staff_and_superuser_see_their_private_subscriptions(self): + private_project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + staff = create_user(prefix="subscribed-projects-staff") + staff.is_staff = True + staff.save(update_fields=["is_staff"]) + superuser = create_staff_user(prefix="subscribed-projects-superuser") + + for admin_user in (staff, superuser): + with self.subTest(user=admin_user.email): + self.subscribe(private_project, admin_user) + self.authenticate(admin_user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), [private_project.pk]) + + def test_lost_access_hides_project_without_removing_subscription(self): + project = create_project( + leader=self.leader, + draft=True, + is_public=False, + ) + collaboration = create_collaborator(project, user=self.user) + self.subscribe(project) + self.authenticate() + self.assertEqual(self.response_ids(self.client.get(self.url)), [project.pk]) + + collaboration.delete() + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.response_ids(response), []) + self.assertTrue(project.subscribers.filter(pk=self.user.pk).exists()) + + def test_search_is_trimmed_case_insensitive_and_subscription_scoped(self): + match = self.create_public_project(name="Solar Laboratory") + non_match = self.create_public_project(name="Marine Laboratory") + foreign_match = self.create_public_project(name="Solar Foreign") + self.subscribe(match) + self.subscribe(non_match) + self.subscribe(foreign_match, self.other_user) + self.authenticate() + + response = self.client.get(self.url, {"search": " sOLAR labORATORY "}) + empty_search = self.client.get(self.url, {"search": " "}) + + self.assertEqual(self.response_ids(response), [match.pk]) + self.assertEqual( + set(self.response_ids(empty_search)), + {match.pk, non_match.pk}, + ) + + def test_page_pagination_uses_existing_shape_and_size(self): + projects = [ + self.create_public_project(name=f"Paginated {index}") for index in range(11) + ] + for project in projects: + self.subscribe(project) + self.authenticate() + + first_page = self.client.get(self.url, {"page": 1}) + second_page = self.client.get(self.url, {"page": 2}) + missing_page = self.client.get(self.url, {"page": 99}) + + self.assertEqual(first_page.status_code, 200) + self.assertEqual(set(first_page.data), {"count", "next", "previous", "results"}) + self.assertEqual(first_page.data["count"], 11) + self.assertEqual(len(first_page.data["results"]), 10) + self.assertIn("page=2", first_page.data["next"]) + self.assertIsNone(first_page.data["previous"]) + self.assertEqual(second_page.status_code, 200) + self.assertEqual(len(second_page.data["results"]), 1) + self.assertIsNotNone(second_page.data["previous"]) + self.assertIsNone(second_page.data["next"]) + self.assertEqual(missing_page.status_code, 404) + + def test_invalid_page_returns_safe_not_found(self): + self.authenticate() + + for page in ("invalid", "0", "-1"): + with self.subTest(page=page): + response = self.client.get(self.url, {"page": page}) + self.assertEqual(response.status_code, 404) + self.assertEqual(set(response.data), {"detail"}) + + def test_projects_are_ordered_by_update_time_and_stable_id(self): + older = self.create_public_project(name="Older") + first_same_time = self.create_public_project(name="Same time first") + second_same_time = self.create_public_project(name="Same time second") + now = timezone.now() + Project.objects.filter(pk=older.pk).update( + datetime_updated=now - timedelta(days=1) + ) + Project.objects.filter(pk__in=(first_same_time.pk, second_same_time.pk)).update( + datetime_updated=now + ) + for project in (older, first_same_time, second_same_time): + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + + self.assertEqual( + self.response_ids(response), + [second_same_time.pk, first_same_time.pk, older.pk], + ) + + def test_card_contract_has_no_subscriber_or_personal_data(self): + project = self.create_public_project() + self.subscribe(project) + self.authenticate() + + response = self.client.get(self.url) + card = response.data["results"][0] + + self.assertEqual( + set(card), + { + "id", + "name", + "short_description", + "image_address", + "cover_image_address", + "draft", + "is_public", + "current_user_role", + "can_edit", + "can_use_in_application", + "activities", + "datetime_updated", + }, + ) + for forbidden in ( + "subscribers", + "email", + "phone_number", + "birthday", + "invitations", + ): + self.assertNotIn(forbidden, card) + + def test_get_does_not_change_domain_objects(self): + project = self.create_public_project() + self.subscribe(project) + Invite.objects.create( + project=project, + user=self.user, + invited_by=self.leader, + ) + News.objects.create(content_object=project, text="Existing project news") + before = { + "projects": Project.objects.count(), + "subscriptions": Project.subscribers.through.objects.count(), + "collaborators": Collaborator.objects.count(), + "invites": Invite.objects.count(), + "news": News.objects.count(), + } + updated_at = project.datetime_updated + self.authenticate() + + response = self.client.get(self.url) + project.refresh_from_db() + + self.assertEqual(response.status_code, 200) + self.assertEqual( + { + "projects": Project.objects.count(), + "subscriptions": Project.subscribers.through.objects.count(), + "collaborators": Collaborator.objects.count(), + "invites": Invite.objects.count(), + "news": News.objects.count(), + }, + before, + ) + self.assertEqual(project.datetime_updated, updated_at) + + def test_serializer_uses_only_prefetched_data(self): + projects = [self.create_public_project() for _index in range(3)] + for project in projects: + self.subscribe(project) + Application.objects.create( + program=create_partner_program(), + user=self.user, + created_by=self.user, + project=project, + ) + prepared_projects = list(get_subscribed_projects_queryset(user=self.user)) + request = APIRequestFactory().get(self.url) + request.user = self.user + + with self.assertNumQueries(0): + data = ProjectWorkspaceListSerializer( + prepared_projects, + many=True, + context={"request": request}, + ).data + + self.assertEqual(len(data), 3) + self.assertTrue(all(item["activities"] for item in data)) + + def test_endpoint_query_budget_does_not_grow_with_project_count(self): + projects = [self.create_public_project() for _index in range(5)] + for project in projects: + self.subscribe(project) + Application.objects.create( + program=create_partner_program(), + user=self.user, + created_by=self.user, + project=project, + ) + self.authenticate() + + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url, {"page": 1}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 5) + self.assertLessEqual(len(queries), 5) diff --git a/projects/urls.py b/projects/urls.py index 1357b7d2..eac4b9b8 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -41,6 +41,7 @@ from projects.workspace_views import ( MyProjectsView, ProjectCatalogView, + SubscribedProjectsView, ProjectWorkspaceCreateView, ProjectWorkspaceDetailView, ProjectWorkspaceSubscriptionView, @@ -81,6 +82,11 @@ path("", ProjectList.as_view()), path("catalog/", ProjectCatalogView.as_view(), name="workspace-catalog"), path("my/", MyProjectsView.as_view(), name="workspace-my"), + path( + "subscribed/", + SubscribedProjectsView.as_view(), + name="workspace-subscribed", + ), path( "workspace/", ProjectWorkspaceCreateView.as_view(), diff --git a/projects/workspace_selectors.py b/projects/workspace_selectors.py index c87f6adf..4265626e 100644 --- a/projects/workspace_selectors.py +++ b/projects/workspace_selectors.py @@ -30,7 +30,7 @@ def _with_workspace_relations(queryset: QuerySet[Project], user) -> QuerySet[Pro applications = Application.objects.select_related("program").order_by( "-updated_at", "-id" ) - return queryset.select_related("leader", "industry").prefetch_related( + return queryset.prefetch_related( Prefetch( "collaborator_set", queryset=current_user_collaborations, @@ -64,6 +64,17 @@ def get_user_projects_queryset(*, user, search=None): return _with_workspace_relations(queryset, user).order_by("-datetime_updated", "-id") +def get_subscribed_projects_queryset(*, user, search=None): + """Возвращает пересечение подписок пользователя и доступных workspace-проектов.""" + queryset = Project.objects.filter(subscribers=user) + queryset = filter_workspace_visible_projects(queryset, user=user) + if search and search.strip(): + queryset = queryset.filter(name__icontains=search.strip()) + return _with_workspace_relations(queryset.distinct(), user).order_by( + "-datetime_updated", "-id" + ) + + def get_workspace_project_queryset(*, user): """Готовит detail queryset с безопасными пользовательскими связями проекта.""" collaborators = Collaborator.objects.select_related("user").order_by( @@ -80,8 +91,9 @@ def get_workspace_project_queryset(*, user): .prefetch_related(Prefetch("required_skills", queryset=required_skills)) .order_by("-datetime_created", "-id") ) + detail_projects = Project.objects.select_related("leader", "industry") queryset = annotate_workspace_subscription_state( - _with_workspace_relations(Project.objects.all(), user), + _with_workspace_relations(detail_projects, user), user=user, ) return queryset.prefetch_related( diff --git a/projects/workspace_views.py b/projects/workspace_views.py index 777868b1..33e64423 100644 --- a/projects/workspace_views.py +++ b/projects/workspace_views.py @@ -6,10 +6,11 @@ from rest_framework.views import APIView from projects.models import Project -from projects.pagination import ProjectsPagination +from projects.pagination import ProjectsPagination, SubscribedProjectsPagination from projects.workspace_selectors import ( filter_workspace_visible_projects, get_project_catalog_queryset, + get_subscribed_projects_queryset, get_user_projects_queryset, get_workspace_project_queryset, get_workspace_subscription_queryset, @@ -70,6 +71,20 @@ def get_queryset(self): ) +class SubscribedProjectsView(generics.ListAPIView): + """Доступные workspace-проекты из подписок текущего пользователя.""" + + permission_classes = [IsAuthenticated] + serializer_class = ProjectWorkspaceListSerializer + pagination_class = SubscribedProjectsPagination + + def get_queryset(self): + return get_subscribed_projects_queryset( + user=self.request.user, + search=self.request.query_params.get("search"), + ) + + class ProjectWorkspaceCreateView(APIView): """Создает самостоятельный приватный черновик для React workspace."""