Skip to content

Commit cd427c6

Browse files
authored
Merge pull request #733 from PROCOLLAB-github/prod/program-case-field-foundation
Prod: formalize program case field contract
2 parents dca8789 + 30c4e31 commit cd427c6

20 files changed

Lines changed: 1932 additions & 107 deletions

‎docs/program-case-field.md‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Системное поле кейса программы (Production, legacy Project v1)
2+
3+
## Хранение и конфигурация
4+
5+
Отдельной модели `Case` нет. Уже существующий `PartnerProgramField` с **точным**
6+
`name="case"` — системное поле. Единая константа: `PROGRAM_CASE_FIELD_NAME`.
7+
`label` произвольный; `Кейс`, `Case`, `case_name`, `track` не являются служебными
8+
именами и автоматически не преобразуются.
9+
10+
Обязательная конфигурация: `field_type="select"`, `is_required=true`,
11+
`show_filter=true`. В `options` остаётся существующая строка с разделителем `|`.
12+
Требуется хотя бы один вариант; после trim пустые варианты запрещены, включая
13+
завершающий `|`. Дубли сравниваются через trim + casefold. API отдаёт варианты
14+
массивом через существующий `get_options_list()`; сами сохранённые выборы
15+
сравниваются с вариантами точно, без нечёткого сопоставления.
16+
17+
Выбор хранится в `PartnerProgramFieldValue.value_text`, привязанном к конкретному
18+
`PartnerProgramProject` (Project × Program). Один проект может выбрать A в одной
19+
программе и B в другой. Ни глобальное поле Project, ни «первая программа» для
20+
определения кейса не используются.
21+
22+
Нет case definition — нет новых обязательств. Автосоздания кейсов, изменений
23+
существующих данных, моделей, FK, колонок и миграций нет. Ограничение уникальности
24+
`(partner_program, name)` остаётся прежним.
25+
26+
## Жизненный цикл
27+
28+
1. `POST /programs/{programId}/projects/apply/` создаёт draft без case, если он
29+
не передан. Первый вариант автоматически не выбирается. **Все остальные**
30+
required fields по-прежнему обязательны на этом этапе.
31+
2. Если case передан явно, select validation обязательна, в том числе для пустого
32+
значения. Допустимый выбранный вариант сохраняется.
33+
3. До сдачи лидер меняет case обычным PUT program fields, в пределах списка.
34+
4. `POST /programs/partner-program-projects/{programLinkId}/submit/` проверяет
35+
case именно этой связи. Отсутствие/пустое значение даёт 400:
36+
37+
```json
38+
{"detail": "Выберите кейс перед сдачей проекта."}
39+
```
40+
41+
Старое значение вне текущих options даёт 400:
42+
43+
```json
44+
{"detail": "Выбранный кейс больше недоступен. Выберите актуальный кейс."}
45+
```
46+
47+
5. При отказе `submitted` и `datetime_submitted` не меняются. При успешной сдаче
48+
конкурсного проекта case и остальные field values заблокированы, как раньше.
49+
Manager/expert/staff override не добавлен. Сроки и остальные условия сдачи
50+
сохранены; для программы без case действует прежний сценарий.
51+
52+
PUT и submit атомарны и блокируют строку связи. Запись case также блокирует
53+
definition; её обычное редактирование блокирует ту же строку. Это защищает от
54+
одновременной сдачи/записи и удаления варианта во время выбора. Повторная проверка
55+
явного case в apply выполняется внутри транзакции перед созданием draft.
56+
57+
## Стабильность истории и Django Admin
58+
59+
Обычные `Model.save()` и admin ModelForm используют общую валидацию:
60+
61+
- добавить вариант или удалить неиспользуемый — можно;
62+
- изменить `label` / `help_text` — можно;
63+
- удалить или переименовать используемый текстовый вариант — нельзя;
64+
- сменить `name="case"` или перенести definition в другую программу при
65+
существующих значениях — нельзя.
66+
67+
Кейсы по-прежнему создаются существующим inline `PartnerProgramField`, отдельной
68+
Case Admin нет. Стандартный inline formset запрещает удаление использованного
69+
case field. Стандартный экран удаления отдельного field (включая bulk action)
70+
показывает его как protected. Неиспользованный field удалить можно.
71+
72+
Ограничения v1: это не database trigger. Прямые SQL, `QuerySet.update()`,
73+
`bulk_create()` / `QuerySet.delete()` и удаление всей родительской программы
74+
администратором могут обходить обычную model/form validation. Такие операции
75+
не являются способом редактирования кейсов и требуют отдельной согласованной
76+
миграции данных. Административное ручное редактирование флага `submitted` связи
77+
не заменяет пользовательский submit endpoint. Отдельного действия массового
78+
переименования кейса пока нет: в будущем оно должно атомарно менять options и
79+
все соответствующие значения. Исторически некорректные значения автоматически
80+
не исправляются; submit выдаёт контролируемую ошибку.
81+
82+
### Неизменённые inline при сдаче через Admin
83+
84+
Заморозка запрещает **мутацию**, а не повторную validation существующей строки.
85+
Model validation сравнивает `program_project_id`, `field_id`, `value_text`
86+
с сохранённой строкой по pk. Новый объект, перенос, смена поля или текста —
87+
мутация; неизменённая строка может повторно валидироваться после сдачи.
88+
Проверка допустимости самого case value при этом не отключается.
89+
90+
Учитывается и сохранённый `submitted` родителя, и его несохранённое значение
91+
из текущей admin-формы. Устаревший parent instance, перенос из сданной связи
92+
или снятие checkbox вместе с изменением полей не обходят freeze.
93+
94+
- `submitted=false → true`, существующие inline без изменений: Save успешен.
95+
- `submitted=true`, inline без изменений и правка несвязанной metadata родителя:
96+
Save допустим.
97+
- Одновременная сдача и изменение/добавление/удаление field value: форма отклонена;
98+
сначала отдельно сохранить финальные поля, затем отдельным Save отметить сдачу.
99+
- Изменение/добавление/удаление после сдачи конкурсной связи: запрещено.
100+
101+
Для DELETE используется отдельный inline formset: Django не применяет обычные
102+
model errors удаляемой формы, поэтому проверка выполняется на уровне formset и
103+
возвращает контролируемую validation error. Правила одинаковы для generic и case.
104+
Пользовательские `update_program_link_fields()` и `submit_program_project()`
105+
не меняются: API PUT после сдачи по-прежнему отклоняется, даже если значения те же.
106+
107+
## Канонический link-scoped API
108+
109+
`GET /programs/partner-program-projects/{programLinkId}/fields/`
110+
111+
```json
112+
{
113+
"program_link_id": 700,
114+
"program_id": 12,
115+
"project_id": 55,
116+
"submitted": false,
117+
"fields": [
118+
{
119+
"id": 5,
120+
"name": "case",
121+
"label": "Выберите задачу",
122+
"field_type": "select",
123+
"is_required": true,
124+
"show_filter": true,
125+
"help_text": null,
126+
"options": ["AI для образования", "Цифровой HR"],
127+
"value": "AI для образования"
128+
}
129+
]
130+
}
131+
```
132+
133+
Нет значения — `value: null`. Definitions упорядочены по pk. Значения другой
134+
связи/программы не подмешиваются. GET требует authentication и ограниченного
135+
read involvement: лидер, участник команды/приглашённый по существующему helper,
136+
staff/superuser, manager/expert **именно программы этой связи**. Одна лишь
137+
публичность проекта доступа к этим полям не даёт. Роли программы B не раскрывают
138+
поля программы A, даже если проект общий. Проверка выполняется через `projects/access.py:has_program_link_read_access()`:
139+
independent project-level access либо program-role links с точным PK запрошенной
140+
связи. Все три исходных helper из production #732 сохраняются без изменений;
141+
scoped singular Project detail и WRITE permissions не заменяются DEV-версией.
142+
143+
Для лидера GET использует **3 SQL-запроса** независимо от количества полей:
144+
link + program + project одним join; все values; все definitions. Для остальных
145+
ролей добавляются ограниченные EXISTS проверки доступа, не по числу полей.
146+
Регрессионный тест измеряет HTTP GET при 1 и 20 полях.
147+
148+
`PUT /programs/partner-program-projects/{programLinkId}/fields/`
149+
150+
```json
151+
[{"field_id": 5, "value_text": "Цифровой HR"}]
152+
```
153+
154+
Формат прежний, массив, partial update допустим; ответ 200:
155+
`{"detail": "Значения успешно обновлены"}`. Только лидер соответствующего
156+
Project. Manager/expert/staff не получают право записи. Чужой `field_id`,
157+
повторяющиеся IDs, неверный select, запись в сданную конкурсную связь — 400.
158+
Чужой field ID проверяется в queryset программы до раскрытия его вариантов.
159+
Невалидный элемент отклоняет весь запрос. Anonymous — 401, отсутствие доступа —
160+
403, отсутствующая связь — 404. Поддерживаются GET/PUT, не POST/DELETE.
161+
162+
## Legacy API
163+
164+
`PUT /projects/{projectId}/program-fields/` сохраняется:
165+
166+
- одна связь — тот же атомарный механизм записи;
167+
- нет связей — контролируемая 400;
168+
- несколько — 409, без записи и без выбора первой:
169+
170+
```json
171+
{"detail": "Проект связан с несколькими программами. Укажите конкретную связь программы."}
172+
```
173+
174+
Следующий Angular этап должен использовать `programLinkId`, не подставлять
175+
`options[0]` на apply и показывать placeholder «Выберите кейс» до явного выбора.
176+
177+
## Совместимость фильтров и границы
178+
179+
`GET /programs/{programId}/filters/` возвращает case через существующее
180+
`show_filter=true`. Manager filter `POST /programs/{programId}/projects/filter/`
181+
и expert filter `POST /rate-project/{programId}` принимают без изменений:
182+
183+
```json
184+
{"filters": {"case": ["AI для образования", "Цифровой HR"]}}
185+
```
186+
187+
Внутри поля сохраняется OR semantics выбранных вариантов. Разрешения rating,
188+
критерии и scoring не изменены. Тесты проверяют A, B, A+B и независимые значения
189+
одного Project в разных программах.
190+
191+
Analytics по кейсам, отдельная модель кейса, автоматическое исправление старых
192+
данных и frontend — следующие отдельные этапы, не часть этого PR.
193+
194+
## Границы production foundation
195+
196+
Это semantic port DEV #728 поверх production #732. Application, Team, Submission,
197+
SubmissionExpertAssignment, Evaluation и их admin/API/migrations не изменяются.
198+
`/programs/{programId}/manager-overview/` сохраняет новый production contract.
199+
200+
Metadata follow-up #729 намеренно не входит: `is_competitive`, `submission_open`,
201+
`submission_deadline`, `can_submit` не добавлены в GET fields. Аналитика,
202+
`current_project_application` и evaluation deadline остаются отдельными этапами.
203+
204+
Перед включением на сервере нужен read-only inventory существующих definitions
205+
с exact `name="case"` и их values: некорректные flags/options и obsolete choices
206+
не исправляются автоматически. Никаких новых schema/data migrations в foundation нет.

‎partner_programs/admin.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
TeamMember,
3131
)
3232
from partner_programs.services import prepare_project_scores_export_data
33+
from partner_programs.forms import (
34+
ProgramFieldInlineFormSet,
35+
ProgramFieldValueInlineFormSet,
36+
)
37+
from partner_programs.services.case_fields import case_field_has_values
3338

3439

3540
@admin.register(Application)
@@ -447,6 +452,7 @@ class PartnerProgramMaterialInline(admin.StackedInline):
447452

448453
class PartnerProgramFieldInline(admin.TabularInline):
449454
model = PartnerProgramField
455+
formset = ProgramFieldInlineFormSet
450456
extra = 0
451457

452458

@@ -732,6 +738,7 @@ def has_file(self, obj):
732738

733739
class PartnerProgramFieldValueInline(admin.TabularInline):
734740
model = PartnerProgramFieldValue
741+
formset = ProgramFieldValueInlineFormSet
735742
extra = 0
736743
autocomplete_fields = ("field",)
737744
readonly_fields = ("get_display_value",)
@@ -760,6 +767,16 @@ class PartnerProgramProjectAdmin(admin.ModelAdmin):
760767

761768
@admin.register(PartnerProgramField)
762769
class PartnerProgramFieldAdmin(admin.ModelAdmin):
770+
def get_deleted_objects(self, objs, request):
771+
"""Use Django's standard protected deletion UI for single and bulk actions."""
772+
deleted, counts, permissions_needed, protected = super().get_deleted_objects(
773+
objs, request
774+
)
775+
for obj in objs:
776+
if case_field_has_values(obj):
777+
protected.append(f"Используемое поле кейса: {obj}")
778+
return deleted, counts, permissions_needed, protected
779+
763780
list_display = (
764781
"id",
765782
"partner_program",

‎partner_programs/constants.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
PROGRAM_CASE_FIELD_NAME = "case"
2+
3+
14
DEFAULT_DATA_SCHEMA = {
25
"field": {"type": "text"},
36
}

‎partner_programs/forms.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Admin integration uses ordinary ModelForm validation, not a separate Case admin."""
2+
3+
from django.core.exceptions import ValidationError
4+
from django.forms.models import BaseInlineFormSet
5+
6+
from partner_programs.services.case_fields import case_field_has_values
7+
from partner_programs.services.field_values import (
8+
FIELD_VALUES_FROZEN_MESSAGE,
9+
program_field_values_are_frozen,
10+
)
11+
12+
13+
class ProgramFieldInlineFormSet(BaseInlineFormSet):
14+
"""Do not cascade-delete used case values through a definition inline."""
15+
16+
def clean(self):
17+
super().clean()
18+
for form in self.deleted_forms:
19+
if case_field_has_values(form.instance):
20+
raise ValidationError(
21+
"Нельзя удалить системное поле с выбранными кейсами."
22+
)
23+
24+
25+
class ProgramFieldValueInlineFormSet(BaseInlineFormSet):
26+
"""Admin skips model errors on DELETE forms, so enforce frozen deletion here."""
27+
28+
def clean(self):
29+
super().clean()
30+
if any(form.instance.pk for form in self.deleted_forms):
31+
if program_field_values_are_frozen(self.instance):
32+
raise ValidationError(FIELD_VALUES_FROZEN_MESSAGE)

0 commit comments

Comments
 (0)