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
66 changes: 66 additions & 0 deletions docs/my-project-list-contract.md
Original file line number Diff line number Diff line change
@@ -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-проверка отложена.
19 changes: 16 additions & 3 deletions users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

Expand Down
175 changes: 175 additions & 0 deletions users/tests/test_project_list_program_contract.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 17 additions & 3 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
)

Expand All @@ -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()
)

Expand Down
Loading