From 3b0724668185f5d81d44ba327b045f5fec5ad6ab Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 7 Sep 2026 00:18:58 +0300 Subject: [PATCH] Expose program link submission metadata --- docs/program-case-field.md | 26 ++++ partner_programs/services/project_fields.py | 6 + .../tests/test_program_link_fields_api.py | 119 +++++++++++++++++- projects/tests/test_project_programs.py | 31 +++++ 4 files changed, 181 insertions(+), 1 deletion(-) diff --git a/docs/program-case-field.md b/docs/program-case-field.md index 0b53595d..994e2129 100644 --- a/docs/program-case-field.md +++ b/docs/program-case-field.md @@ -114,6 +114,10 @@ model errors удаляемой формы, поэтому проверка вы "program_id": 12, "project_id": 55, "submitted": false, + "is_competitive": true, + "submission_open": true, + "submission_deadline": "2026-10-01T18:00:00Z", + "can_submit": true, "fields": [ { "id": 5, @@ -138,6 +142,23 @@ staff/superuser, manager/expert **именно программы этой св поля программы A, даже если проект общий. Старые вызовы visibility helper без program scope сохраняют существующую семантику. +Metadata описывает **запрошенную** связь `PartnerProgramProject`, а не первую +программу проекта: + +- `is_competitive: boolean` — `link.partner_program.is_competitive`; +- `submission_open: boolean` — результат + `link.partner_program.is_project_submission_open()`; +- `submission_deadline: datetime | null` — результат + `link.partner_program.get_project_submission_deadline()`, в JSON — ISO 8601 + либо null. Сохраняется существующий fallback на окончание регистрации; +- `can_submit: boolean` — `is_competitive and not link.submitted and submission_open`. + +У сданной либо неконкурсной связи `can_submit=false`, даже если окно ещё открыто. +Это snapshot доступности стадии сдачи, не новое разрешение: проверка прав лидера, +обязательных полей и остальные проверки submit endpoint остаются неизменными. +`fields` в GET и формат PUT не меняются. Metadata вычисляется из уже загруженных +link/program и не добавляет SQL-запросов. + Для лидера GET использует **3 SQL-запроса** независимо от количества полей: link + program + project одним join; все values; все definitions. Для остальных ролей добавляются ограниченные EXISTS проверки доступа, не по числу полей. @@ -172,6 +193,11 @@ Project. Manager/expert/staff не получают право записи. Ч Следующий Angular этап должен использовать `programLinkId`, не подставлять `options[0]` на apply и показывать placeholder «Выберите кейс» до явного выбора. +Обычный `PATCH /projects/{projectId}/` без ключа `partner_program_id` не меняет +привязки к программам. Регрессия проверяет проект с двумя связями A/B: обе строки +связей и связанные профили участников сохраняются без изменений. Явная смена +программы через legacy поле остаётся прежним отдельным поведением. + ## Совместимость фильтров и границы `GET /programs/{programId}/filters/` возвращает case через существующее diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py index d36770e1..92e75bdc 100644 --- a/partner_programs/services/project_fields.py +++ b/partner_programs/services/project_fields.py @@ -62,11 +62,17 @@ def program_link_fields(link_id, user): fields = PartnerProgramFieldSerializer( link.partner_program.fields.order_by("pk"), many=True ).data + program = link.partner_program + submission_open = program.is_project_submission_open() return { "program_link_id": link.pk, "program_id": link.partner_program_id, "project_id": link.project_id, "submitted": link.submitted, + "is_competitive": program.is_competitive, + "submission_open": submission_open, + "submission_deadline": program.get_project_submission_deadline(), + "can_submit": program.is_competitive and not link.submitted and submission_open, "fields": [{**field, "value": values.get(field["id"])} for field in fields], } diff --git a/partner_programs/tests/test_program_link_fields_api.py b/partner_programs/tests/test_program_link_fields_api.py index 8a41a984..5312dfe9 100644 --- a/partner_programs/tests/test_program_link_fields_api.py +++ b/partner_programs/tests/test_program_link_fields_api.py @@ -5,9 +5,11 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from django.utils import timezone +from django.utils.dateparse import parse_datetime from rest_framework.test import APIClient -from partner_programs.models import PartnerProgramFieldValue +from partner_programs.models import PartnerProgram, PartnerProgramFieldValue from partner_programs.tests.helpers import ( create_partner_program, create_program_field, @@ -53,6 +55,10 @@ def test_get_context_order_null_value_and_other_program_isolation(self): "program_id": self.program.pk, "project_id": self.project.pk, "submitted": False, + "is_competitive": True, + "submission_open": True, + "submission_deadline": self.program.datetime_registration_ends, + "can_submit": True, }, ) fields = response.data["fields"] @@ -74,6 +80,115 @@ def test_get_context_order_null_value_and_other_program_isolation(self): }, ) + def assert_submission_metadata( + self, link, *, competitive, submission_open, deadline, can_submit + ): + response = self.client.get( + f"/programs/partner-program-projects/{link.pk}/fields/" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["program_link_id"], link.pk) + self.assertEqual(data["program_id"], link.partner_program_id) + self.assertIs(data["is_competitive"], competitive) + self.assertIs(data["submission_open"], submission_open) + self.assertIs(data["can_submit"], can_submit) + if deadline is None: + self.assertIsNone(data["submission_deadline"]) + else: + self.assertEqual(parse_datetime(data["submission_deadline"]), deadline) + + def test_competitive_open_metadata_uses_submission_deadline(self): + deadline = timezone.now() + timezone.timedelta(days=2) + self.program.datetime_project_submission_ends = deadline + self.program.save(update_fields=["datetime_project_submission_ends"]) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=True, + deadline=deadline, + can_submit=True, + ) + + def test_competitive_closed_metadata(self): + deadline = timezone.now() - timezone.timedelta(days=1) + self.program.datetime_project_submission_ends = deadline + self.program.save(update_fields=["datetime_project_submission_ends"]) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=False, + deadline=deadline, + can_submit=False, + ) + + def test_already_submitted_metadata_keeps_window_open_but_cannot_submit(self): + self.link.submitted = True + self.link.save(update_fields=["submitted"]) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=True, + deadline=self.program.datetime_registration_ends, + can_submit=False, + ) + + def test_noncompetitive_metadata_keeps_window_open_but_cannot_submit(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.assert_submission_metadata( + self.link, + competitive=False, + submission_open=True, + deadline=self.program.datetime_registration_ends, + can_submit=False, + ) + + def test_metadata_preserves_nullable_deadline_from_program_method(self): + # Current records require a registration deadline; preserve the helper contract + # without changing the schema to manufacture an otherwise impossible record. + with patch.object( + PartnerProgram, "get_project_submission_deadline", return_value=None + ): + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=True, + deadline=None, + can_submit=True, + ) + + def test_metadata_uses_requested_link_when_project_has_different_program_states(self): + closed_deadline = timezone.now() - timezone.timedelta(days=1) + self.program.datetime_project_submission_ends = closed_deadline + self.program.save(update_fields=["datetime_project_submission_ends"]) + self.link.submitted = True + self.link.save(update_fields=["submitted"]) + # B is deliberately not the first link, and must not inherit A's closed/submitted state. + self.assert_submission_metadata( + self.other_link, + competitive=True, + submission_open=True, + deadline=self.other_program.datetime_registration_ends, + can_submit=True, + ) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=False, + deadline=closed_deadline, + can_submit=False, + ) + self.other_program.is_competitive = False + self.other_program.save(update_fields=["is_competitive"]) + self.assert_submission_metadata( + self.other_link, + competitive=False, + submission_open=True, + deadline=self.other_program.datetime_registration_ends, + can_submit=False, + ) + def test_partial_update_changes_only_requested_link(self): extra = create_program_field(self.program) self.put([{"field_id": extra.pk, "value_text": "preserve"}]) @@ -255,6 +370,7 @@ def test_get_uses_three_queries_for_one_or_twenty_fields(self): with self.assertNumQueries(3): first = self.client.get(self.url) self.assertEqual(len(first.data["fields"]), 1) + self.assertIs(first.data["can_submit"], True) for _ in range(19): field = create_program_field(self.program) PartnerProgramFieldValue.objects.create( @@ -263,3 +379,4 @@ def test_get_uses_three_queries_for_one_or_twenty_fields(self): with self.assertNumQueries(3): many = self.client.get(self.url) self.assertEqual(len(many.data["fields"]), 20) + self.assertIs(many.data["can_submit"], True) diff --git a/projects/tests/test_project_programs.py b/projects/tests/test_project_programs.py index fd3e43ef..949fffed 100644 --- a/projects/tests/test_project_programs.py +++ b/projects/tests/test_project_programs.py @@ -7,6 +7,7 @@ add_program_member, create_industry, create_partner_program, + create_project, create_user, ) @@ -18,6 +19,36 @@ def setUp(self): self.industry = create_industry() self.client.force_authenticate(self.user) + def test_patch_without_partner_program_id_preserves_both_program_links(self): + project = create_project(leader=self.user, industry=self.industry) + programs = [create_partner_program(), create_partner_program()] + for program in programs: + PartnerProgramProject.objects.create(partner_program=program, project=project) + profile = add_program_member(program, self.user) + profile.project = project + profile.save(update_fields=["project"]) + links = PartnerProgramProject.objects.filter(project=project).order_by("pk") + profiles = PartnerProgramUserProfile.objects.filter(project=project).order_by( + "pk" + ) + before_links = list(links.values()) + before_profiles = list(profiles.values()) + + response = self.client.patch( + f"/projects/{project.pk}/", + { + "name": "Updated without changing program links", + "description": project.description, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200, response.data) + project.refresh_from_db() + self.assertEqual(project.name, "Updated without changing program links") + self.assertEqual(list(links.values()), before_links) + self.assertEqual(list(profiles.values()), before_profiles) + def test_program_member_can_create_project_bound_to_program(self): program = create_partner_program() member_profile = add_program_member(program, self.user)