From 6df9f6b61cda4940bab8bb125e715253081a99a8 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 9 Sep 2026 17:36:12 +0300 Subject: [PATCH] Expose link-scoped submission metadata in program fields GET --- docs/program-case-field.md | 36 ++++- partner_programs/services/project_fields.py | 6 + .../tests/test_program_link_fields_api.py | 143 +++++++++++++++++- projects/tests/test_project_programs.py | 31 ++++ 4 files changed, 206 insertions(+), 10 deletions(-) diff --git a/docs/program-case-field.md b/docs/program-case-field.md index 2a4aac2c..e55200cc 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, @@ -140,10 +144,29 @@ independent project-level access либо program-role links с точным PK связи. Все три исходных helper из production #732 сохраняются без изменений; scoped singular Project detail и WRITE permissions не заменяются DEV-версией. +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. + Сохраняется приоритет `datetime_project_submission_ends` и fallback на окончание + регистрации; +- `can_submit: boolean` — `is_competitive and not link.submitted and submission_open`. + +У сданной или неконкурсной связи `can_submit=false`, даже если окно сдачи открыто. +Это snapshot стадии, не authorization policy: значение одинаково для всех читателей +этой связи и не проверяет выбор case. Authoritative submit endpoint по-прежнему +проверяет лидера, case, deadline и остальные условия. GET/PUT/submit permissions +#732/#733 неизменны. `fields` в GET и ответ PUT не меняются. +Metadata берётся из уже загруженных link/program и не добавляет SQL. + Для лидера GET использует **3 SQL-запроса** независимо от количества полей: link + program + project одним join; все values; все definitions. Для остальных ролей добавляются ограниченные EXISTS проверки доступа, не по числу полей. -Регрессионный тест измеряет HTTP GET при 1 и 20 полях. +Для manager/expert измерено **6 SQL-запросов**. Регрессионный тест измеряет HTTP GET +при 1 и 20 полях для всех трёх ролей; число запросов не растёт с количеством fields. `PUT /programs/partner-program-projects/{programLinkId}/fields/` @@ -174,6 +197,10 @@ Project. Manager/expert/staff не получают право записи. Ч Следующий Angular этап должен использовать `programLinkId`, не подставлять `options[0]` на apply и показывать placeholder «Выберите кейс» до явного выбора. +Обычный `PATCH /projects/{projectId}/` без `partner_program_id` не меняет связи +проекта с программами или профили участников. Регрессия проверяет сохранение обеих +связей A/B и всех значений связанных профилей; production Project runtime не меняется. + ## Совместимость фильтров и границы `GET /programs/{programId}/filters/` возвращает case через существующее @@ -197,9 +224,10 @@ Analytics по кейсам, отдельная модель кейса, авт SubmissionExpertAssignment, Evaluation и их admin/API/migrations не изменяются. `/programs/{programId}/manager-overview/` сохраняет новый production contract. -Metadata follow-up #729 намеренно не входит: `is_competitive`, `submission_open`, -`submission_deadline`, `can_submit` не добавлены в GET fields. Аналитика, -`current_project_application` и evaluation deadline остаются отдельными этапами. +Поверх production #733 выполнен отдельный minimal semantic port DEV #729: +`is_competitive`, `submission_open`, `submission_deadline`, `can_submit` добавлены +только в canonical GET fields. PUT, submit и access helpers foundation не изменены. +Аналитика, `current_project_application` и evaluation deadline остаются отдельными этапами. Перед включением на сервере нужен read-only inventory существующих definitions с exact `name="case"` и их values: некорректные flags/options и obsolete choices diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py index ef6bf23e..f02c4779 100644 --- a/partner_programs/services/project_fields.py +++ b/partner_programs/services/project_fields.py @@ -60,11 +60,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 da96d97a..9f294445 100644 --- a/partner_programs/tests/test_program_link_fields_api.py +++ b/partner_programs/tests/test_program_link_fields_api.py @@ -6,10 +6,12 @@ from django.db import connection from django.test import TestCase from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from django.utils.dateparse import parse_datetime from rest_framework.test import APIClient from invites.models import Invite -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, @@ -55,6 +57,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"] @@ -76,6 +82,128 @@ 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["submitted"], link.submitted) + 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_uses_registration_deadline_fallback(self): + self.assertIsNone(self.program.datetime_project_submission_ends) + for days, submission_open in ((2, True), (-1, False)): + with self.subTest(days=days): + deadline = timezone.now() + timezone.timedelta(days=days) + self.program.datetime_registration_ends = deadline + self.program.save(update_fields=["datetime_registration_ends"]) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=submission_open, + deadline=deadline, + can_submit=submission_open, + ) + + def test_metadata_preserves_nullable_deadline_from_program_method(self): + # Existing rows require a registration deadline; exercise 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): + open_deadline = timezone.now() + timezone.timedelta(days=2) + closed_deadline = timezone.now() - timezone.timedelta(days=1) + self.program.datetime_project_submission_ends = open_deadline + self.program.save(update_fields=["datetime_project_submission_ends"]) + self.other_program.is_competitive = False + self.other_program.datetime_project_submission_ends = closed_deadline + self.other_program.save( + update_fields=["is_competitive", "datetime_project_submission_ends"] + ) + self.other_link.submitted = True + self.other_link.save(update_fields=["submitted"]) + self.assert_submission_metadata( + self.link, + competitive=True, + submission_open=True, + deadline=open_deadline, + can_submit=True, + ) + # B was linked second; it must not inherit A's open/competitive/draft state. + self.assert_submission_metadata( + self.other_link, + competitive=False, + submission_open=False, + deadline=closed_deadline, + 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"}]) @@ -87,10 +215,9 @@ def test_partial_update_changes_only_requested_link(self): 200, ) for value in ("A", "B"): - self.assertEqual( - self.put([{"field_id": self.field.pk, "value_text": value}]).status_code, - 200, - ) + response = self.put([{"field_id": self.field.pk, "value_text": value}]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"detail": "Значения успешно обновлены"}) self.assertEqual(self.link.field_values.get(field=extra).value_text, "preserve") self.assertEqual(self.link.field_values.get(field=self.field).value_text, "B") self.assertEqual(self.other_link.field_values.get().value_text, "Other") @@ -183,7 +310,10 @@ def test_get_allows_existing_project_involvement_and_current_program_roles(self) ): with self.subTest(user=user.pk): self.client.force_authenticate(user) - self.assertEqual(self.client.get(self.url).status_code, 200) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + # Stage availability is not write permission or case validation. + self.assertIs(response.data["can_submit"], True) def test_program_roles_are_scoped_to_requested_link_after_production_732(self): manager = create_user() @@ -301,6 +431,7 @@ def get_query_counts(self, users, expected_fields): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["fields"]), expected_fields) + self.assertIs(response.data["can_submit"], True) counts.append(len(queries)) return counts 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)