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
36 changes: 32 additions & 4 deletions docs/program-case-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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/`

Expand Down Expand Up @@ -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 через существующее
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions partner_programs/services/project_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}

Expand Down
143 changes: 137 additions & 6 deletions partner_programs/tests/test_program_link_fields_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand All @@ -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"}])
Expand All @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions projects/tests/test_project_programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
add_program_member,
create_industry,
create_partner_program,
create_project,
create_user,
)

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