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
62 changes: 62 additions & 0 deletions docs/prod-project-card-port.md
Original file line number Diff line number Diff line change
@@ -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 не выполняются.
21 changes: 18 additions & 3 deletions users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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

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 @@ -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,
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