diff --git a/docs/prod-project-card-port.md b/docs/prod-project-card-port.md new file mode 100644 index 00000000..91312fcb --- /dev/null +++ b/docs/prod-project-card-port.md @@ -0,0 +1,62 @@ +# Перенос lifecycle списка проектов на PROD + +База master: `a5cdf03a8eef391671c92ccea439e52d4df7cb4f`. +Источник: DEV [#748](https://github.com/PROCOLLAB-github/api/pull/748), +head `2651a1f5e8ea168a9e1fbc193b0bc07fa8f33027`, +merge `19458b68e16d71741f4aeed1d2510d673cfbd3e2`. + +Это выборочный перенос сериализации и prefetch. Ветка DEV целиком не переносится. +PROD-изменения профиля, throttling и ограничений detail сохраняются. + +## Контракт с Angular + +`GET /auth/users/projects/` и `GET /auth/users/projects/leader/` расширяют +`partner_program`: прежние `id/name` дополнены `program_link_id`, `program_id`, +`is_submitted`. `id` остаётся ID программы, `program_link_id` — PK связи, +`is_submitted` — `PartnerProgramProject.submitted`. Без связи возвращается `null`. + +```json +{ + "id": 12, + "name": "Программа", + "program_link_id": 120, + "program_id": 12, + "is_submitted": true +} +``` + +`program_fields`, `program_field_values`, `can_submit` в list не добавляются. +Angular после CamelcaseInterceptor получает `partnerProgram.isSubmitted`. +Сданный проект отображается как «Сдан в программу / только просмотр» независимо +от роли. При неполном старом контракте карточка не обещает редактирование. + +## Выбор связи и доступ + +Для лидера и collaborator список выбирает legacy-связь с минимальным PK, как +существующий detail. Порядок создания, PK программы и порядок prefetch-кеша +не влияют на выбор. PROD detail для читателей только с ролью в программе +сохраняет ограничение eligible links: чужие закрытые данные не раскрываются. +Его реализация и права не меняются. + +Оба list endpoint загружают `program_links` через ordered Prefetch с +`select_related("partner_program").order_by("pk")`. Пустой кеш считается +готовым результатом и не вызывает дополнительный SELECT. + +Query-count regression измеряет 1 → 31 проект для обоих endpoint при прогретом +существующем кеше просмотров. Старые cache miss в `get_views_count` не изменены; +проверка защищает загрузку программ от нового N+1, включая проекты без связи. +Результат для обоих списков: 3 → 3 SQL-запроса (COUNT, страница, JOIN-prefetch). +Сериализация заполненного/пустого prefetch-кеша — 0 SELECT; fallback без кеша — 1. + +## Проверки и последующее развёртывание + +Перенесены contract tests #748: null/false/true, обратный порядок создания и кеша, +min PK, равенство list/detail, лидер/collaborator, отсутствие анонимного доступа +и query count. Дополнительно запускаются PROD access/submission tests, полный +PostgreSQL suite, Django check, Black, Flake8 и проверка отсутствия новых миграций. + +Submission logic, current_application, case, React, зависимости и workflows +не меняются. Для будущего релиза backend-контракт нужен перед Angular-карточками. +После отдельно разрешённого deploy необходимо сравнить list/detail одного +тестового сданного проекта: `program_link_id` и `is_submitted` должны совпасть. +Эта задача готовит Draft PR; merge/deploy и изменение DEV не выполняются. diff --git a/users/serializers.py b/users/serializers.py index 2d9da22f..047c7161 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -960,6 +960,8 @@ class ResendVerifyEmailSerializer(serializers.Serializer): class UserProjectListSerializer(serializers.ModelSerializer[Project]): + """Компактный список проектов пользователя с той же legacy-связью, что в detail.""" + views_count = serializers.SerializerMethodField(method_name="count_views") short_description = serializers.SerializerMethodField() partner_program = serializers.SerializerMethodField() @@ -974,16 +976,29 @@ def get_short_description(cls, project): @staticmethod def get_partner_program(project): + """Возвращает состояние связи с минимальным PK без загрузки полей заявки. + + Даже пустой prefetch — готовый результат: повторный SELECT на каждый + проект без программы создавал бы N+1. Минимальный PK сохраняет семантику + detail независимо от порядка переданного prefetch-кеша. + """ links_cache = getattr(project, "_prefetched_objects_cache", {}).get( "program_links" ) - link = links_cache[0] if links_cache else project.program_links.select_related( - "partner_program" - ).first() + link = ( + min(links_cache, key=lambda item: item.pk, default=None) + if links_cache is not None + else project.program_links.select_related("partner_program") + .order_by("pk") + .first() + ) if link and link.partner_program: return { "id": link.partner_program_id, "name": link.partner_program.name, + "program_link_id": link.pk, + "program_id": link.partner_program_id, + "is_submitted": link.submitted, } return None diff --git a/users/tests/test_project_list_program_contract.py b/users/tests/test_project_list_program_contract.py new file mode 100644 index 00000000..753ab750 --- /dev/null +++ b/users/tests/test_project_list_program_contract.py @@ -0,0 +1,175 @@ +"""Регрессии компактного контракта «Моих проектов» и выбора legacy-связи.""" + +from django.core.cache import cache +from django.db import connection +from django.db.models import Prefetch +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from core.services import get_views_count +from partner_programs.models import PartnerProgramProject +from projects.models import Project +from projects.tests.helpers import ( + add_program_member, + create_collaborator, + create_partner_program, + create_project, + create_user, + link_project_to_program, +) +from users.serializers import UserProjectListSerializer + + +class UserProjectProgramContractTests(TestCase): + endpoints = ("/auth/users/projects/", "/auth/users/projects/leader/") + + def setUp(self): + self.leader = create_user() + self.project = create_project(leader=self.leader) + self.client = APIClient() + self.client.force_authenticate(self.leader) + cache.clear() + + def list_program(self, endpoint): + response = self.client.get(endpoint) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + return response.data["results"][0]["partner_program"] + + def test_no_program_is_null_in_both_lists(self): + for endpoint in self.endpoints: + with self.subTest(endpoint=endpoint): + self.assertIsNone(self.list_program(endpoint)) + + def test_compact_contract_preserves_ids_and_authoritative_submission(self): + link = link_project_to_program(self.project, create_partner_program()) + for submitted in (False, True): + link.submitted = submitted + link.save(update_fields=["submitted"]) + for endpoint in self.endpoints: + with self.subTest(submitted=submitted, endpoint=endpoint): + self.assertEqual( + self.list_program(endpoint), + { + "id": link.partner_program_id, + "name": link.partner_program.name, + "program_link_id": link.pk, + "program_id": link.partner_program_id, + "is_submitted": submitted, + }, + ) + + def create_reversed_links(self): + """PK связи, порядок её создания и PK программы намеренно не совпадают.""" + earlier_program = create_partner_program() + later_program = create_partner_program() + PartnerProgramProject.objects.create( + pk=20, + project=self.project, + partner_program=earlier_program, + submitted=False, + ) + return PartnerProgramProject.objects.create( + pk=10, + project=self.project, + partner_program=later_program, + submitted=True, + ) + + def test_list_and_detail_choose_minimum_link_pk_not_creation_or_program_order(self): + selected = self.create_reversed_links() + detail = self.client.get(f"/projects/{self.project.pk}/") + self.assertEqual(detail.status_code, 200) + expected = { + "program_link_id": selected.pk, + "program_id": selected.partner_program_id, + "is_submitted": True, + } + self.assertEqual( + {key: detail.data["partner_program"][key] for key in expected}, expected + ) + for endpoint in self.endpoints: + with self.subTest(endpoint=endpoint): + result = self.list_program(endpoint) + self.assertEqual({key: result[key] for key in expected}, expected) + + def test_reversed_prefetch_and_uncached_lookup_select_same_link(self): + selected = self.create_reversed_links() + prefetched = Project.objects.prefetch_related( + Prefetch( + "program_links", + queryset=PartnerProgramProject.objects.select_related( + "partner_program" + ).order_by("-pk"), + ) + ).get(pk=self.project.pk) + self.assertEqual(prefetched.program_links.all()[0].pk, 20) + with self.assertNumQueries(0): + cached = UserProjectListSerializer.get_partner_program(prefetched) + with self.assertNumQueries(1): + uncached = UserProjectListSerializer.get_partner_program(self.project) + self.assertEqual(cached, uncached) + self.assertEqual(cached["program_link_id"], selected.pk) + self.assertIs(cached["is_submitted"], True) + + def test_empty_prefetch_does_not_query_again(self): + project = Project.objects.prefetch_related("program_links").get( + pk=self.project.pk + ) + with self.assertNumQueries(0): + self.assertIsNone(UserProjectListSerializer.get_partner_program(project)) + + def test_leader_and_collaborator_see_identical_lifecycle(self): + program = create_partner_program() + link_project_to_program(self.project, program, submitted=True) + collaborator = create_user() + add_program_member(program, collaborator) + create_collaborator(self.project, user=collaborator) + leader_metadata = self.list_program(self.endpoints[0]) + self.client.force_authenticate(collaborator) + self.assertEqual(self.list_program(self.endpoints[0]), leader_metadata) + detail = self.client.get(f"/projects/{self.project.pk}/") + self.assertEqual(detail.status_code, 200) + for field in ("program_link_id", "program_id", "is_submitted"): + self.assertEqual( + detail.data["partner_program"][field], leader_metadata[field] + ) + # Чтение метаданных не превращает участника в лидера. + response = self.client.get(self.endpoints[1]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + + def test_unauthenticated_user_cannot_read_my_projects(self): + self.client.force_authenticate(None) + for endpoint in self.endpoints: + with self.subTest(endpoint=endpoint): + self.assertIn(self.client.get(endpoint).status_code, (401, 403)) + + def test_query_count_is_constant_for_1_and_31_projects(self): + program = create_partner_program() + link_project_to_program(self.project, program, submitted=True) + + def counts(expected_count): + # Счётчик просмотров имеет собственный старый кеш. Прогреваем только + # его, чтобы измерять list/prefetch без посторонних cache miss. + for project in Project.objects.filter(leader=self.leader): + get_views_count(project) + measured = [] + for endpoint in self.endpoints: + with CaptureQueriesContext(connection) as queries: + response = self.client.get(endpoint, {"limit": 100}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), expected_count) + measured.append(len(queries)) + return measured + + one = counts(1) + for index in range(30): + project = create_project(leader=self.leader) + # Смешанный список ловит и N+1 на пустом кеше связей. + if index % 2 == 0: + link_project_to_program(project, program, submitted=False) + thirty_one = counts(31) + self.assertEqual(one, [3, 3]) # COUNT, страница проектов, JOIN-prefetch связей. + self.assertEqual(thirty_one, one) diff --git a/users/views.py b/users/views.py index e0e850d8..7678d9c2 100644 --- a/users/views.py +++ b/users/views.py @@ -35,7 +35,7 @@ from core.throttling import PostOnlyScopedRateThrottle from events.models import Event from events.serializers import EventsListSerializer -from partner_programs.models import PartnerProgram +from partner_programs.models import PartnerProgram, PartnerProgramProject from partner_programs.serializers import ( PartnerProgramListSerializer, UserProgramsSerializer, @@ -417,7 +417,14 @@ def get(self, request): Q(leader_id=self.request.user.id) | Q(collaborator__user=self.request.user) ) - .prefetch_related("program_links__partner_program") + .prefetch_related( + Prefetch( + "program_links", + queryset=PartnerProgramProject.objects.select_related( + "partner_program" + ).order_by("pk"), + ) + ) .distinct() ) @@ -439,7 +446,14 @@ class UserLeaderProjectsList(GenericAPIView): def get(self, request): queryset = ( Project.objects.filter(leader_id=self.request.user.id) - .prefetch_related("program_links__partner_program") + .prefetch_related( + Prefetch( + "program_links", + queryset=PartnerProgramProject.objects.select_related( + "partner_program" + ).order_by("pk"), + ) + ) .distinct() )