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
26 changes: 26 additions & 0 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 @@ -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 проверки доступа, не по числу полей.
Expand Down Expand Up @@ -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 через существующее
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 @@ -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],
}

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