From 2651a1f5e8ea168a9e1fbc193b0bc07fa8f33027 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 21 Sep 2026 23:03:27 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?lifecycle=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D1=81=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/my-project-list-contract.md | 66 +++++++ users/serializers.py | 19 +- .../test_project_list_program_contract.py | 175 ++++++++++++++++++ users/views.py | 20 +- 4 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 docs/my-project-list-contract.md create mode 100644 users/tests/test_project_list_program_contract.py diff --git a/docs/my-project-list-contract.md b/docs/my-project-list-contract.md new file mode 100644 index 00000000..9da0cab9 --- /dev/null +++ b/docs/my-project-list-contract.md @@ -0,0 +1,66 @@ +# Контракт состояния «Моих проектов» + +## Причина и изменение + +Карточка получала наличие программы без признака сдачи, хотя detail уже возвращал +`is_submitted`. Это позволяло показывать лидеру «можно редактировать» для сданного проекта. + +В ответах `GET /auth/users/projects/` и `GET /auth/users/projects/leader/` +поле `partner_program` расширено без удаления старых ключей. + +До изменения: + +```json +{"id": 12, "name": "Программа"} +``` + +После изменения для связи `PartnerProgramProject.pk=120`: + +```json +{ + "id": 12, + "name": "Программа", + "program_link_id": 120, + "program_id": 12, + "is_submitted": true +} +``` + +`id` сохраняет смысл ID программы; `program_link_id` — ID связи проекта с программой. +`is_submitted` берётся непосредственно из `PartnerProgramProject.submitted`. +Без связи возвращается `null`. `can_submit`, `program_fields`, `program_field_values` +в сокращённый контракт не включены. + +## Выбор связи и запросы + +Список, как существующий detail, выбирает связь с минимальным PK. Порядок создания +программ, создания связей или переданного prefetch-кеша не влияет на выбор. +Это поддержка существующего singular-контракта, а не новый выбор программы. + +Оба списка используют `Prefetch("program_links", queryset=...select_related("partner_program").order_by("pk"))`. +Сериализатор использует даже пустой кеш связей без повторного SELECT. Без prefetch +загружает одну связь и программу одним запросом с `ORDER BY pk`. + +Query-count regression сравнивает 1 и 31 проект (часть без программ) для обоих API. +Результат: 3 → 3 запроса для каждого endpoint (COUNT, страница проектов, связи с +программами через JOIN). Сериализация подготовленного кеша связей — 0 запросов, +включая пустой кеш; без prefetch — 1 запрос. +Прогревается только существующий кеш `views_count`: его отдельные cache miss +в `core.services.get_views_count` не относятся к загрузке программ и не изменены. +Таким образом, замер не является заявлением об отсутствии N+1 при холодном кеше просмотров. + +## Границы и проверка + +Фильтры доступа лидера/участника, detail, `current_application`, сдача, case, +аналитика и React-домен не изменены. Миграций и новых зависимостей нет. + +Регрессии: `users.tests.test_project_list_program_contract` — null/false/true, +обратные PK/порядок создания/порядок кеша, совпадение list/detail, одинаковая metadata +для лидера и collaborator, отсутствие доступа анонимного пользователя и query count. +Существующие проверки detail/access, submission и current_application запускаются отдельно. + +Для будущего DEV-развёртывания сначала нужен этот additive backend-контракт, +затем Angular-защита неполного ответа. Merge/deploy этой задачей не выполняются. +После отдельного разрешения на deploy нужно сравнить `program_link_id` и +`is_submitted` из list/detail одного реально сданного DEV-проекта и проверить +«Сдан в программу / Лидер / только просмотр» на карточке. Сейчас live-проверка отложена. diff --git a/users/serializers.py b/users/serializers.py index 07469e05..d88b73cd 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -930,6 +930,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() @@ -944,18 +946,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() + 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 03cee205..e1cff0fb 100644 --- a/users/views.py +++ b/users/views.py @@ -34,7 +34,7 @@ from core.permissions import IsOwnerOrReadOnly 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() )