From 30c4e3132fd92371e528d777097a2428242c1a2f Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 9 Sep 2026 15:23:31 +0300 Subject: [PATCH] Port legacy program case and link-scoped field contract to production --- docs/program-case-field.md | 206 ++++++++++++ partner_programs/admin.py | 17 + partner_programs/constants.py | 3 + partner_programs/forms.py | 32 ++ partner_programs/models.py | 61 +++- partner_programs/serializers/fields.py | 13 +- partner_programs/services/case_fields.py | 89 +++++ partner_programs/services/field_values.py | 61 ++++ partner_programs/services/project_apply.py | 31 +- partner_programs/services/project_fields.py | 120 +++++++ partner_programs/tests/test_case_fields.py | 221 ++++++++++++ .../tests/test_case_filters_api.py | 77 +++++ .../tests/test_case_lifecycle_api.py | 170 ++++++++++ partner_programs/tests/test_case_locking.py | 138 ++++++++ .../tests/test_field_value_admin.py | 194 +++++++++++ .../tests/test_field_value_mutation.py | 156 +++++++++ .../tests/test_program_link_fields_api.py | 318 ++++++++++++++++++ partner_programs/urls.py | 6 + partner_programs/views.py | 118 ++----- projects/access.py | 8 + 20 files changed, 1932 insertions(+), 107 deletions(-) create mode 100644 docs/program-case-field.md create mode 100644 partner_programs/forms.py create mode 100644 partner_programs/services/case_fields.py create mode 100644 partner_programs/services/field_values.py create mode 100644 partner_programs/services/project_fields.py create mode 100644 partner_programs/tests/test_case_fields.py create mode 100644 partner_programs/tests/test_case_filters_api.py create mode 100644 partner_programs/tests/test_case_lifecycle_api.py create mode 100644 partner_programs/tests/test_case_locking.py create mode 100644 partner_programs/tests/test_field_value_admin.py create mode 100644 partner_programs/tests/test_field_value_mutation.py create mode 100644 partner_programs/tests/test_program_link_fields_api.py diff --git a/docs/program-case-field.md b/docs/program-case-field.md new file mode 100644 index 00000000..2a4aac2c --- /dev/null +++ b/docs/program-case-field.md @@ -0,0 +1,206 @@ +# Системное поле кейса программы (Production, legacy Project v1) + +## Хранение и конфигурация + +Отдельной модели `Case` нет. Уже существующий `PartnerProgramField` с **точным** +`name="case"` — системное поле. Единая константа: `PROGRAM_CASE_FIELD_NAME`. +`label` произвольный; `Кейс`, `Case`, `case_name`, `track` не являются служебными +именами и автоматически не преобразуются. + +Обязательная конфигурация: `field_type="select"`, `is_required=true`, +`show_filter=true`. В `options` остаётся существующая строка с разделителем `|`. +Требуется хотя бы один вариант; после trim пустые варианты запрещены, включая +завершающий `|`. Дубли сравниваются через trim + casefold. API отдаёт варианты +массивом через существующий `get_options_list()`; сами сохранённые выборы +сравниваются с вариантами точно, без нечёткого сопоставления. + +Выбор хранится в `PartnerProgramFieldValue.value_text`, привязанном к конкретному +`PartnerProgramProject` (Project × Program). Один проект может выбрать A в одной +программе и B в другой. Ни глобальное поле Project, ни «первая программа» для +определения кейса не используются. + +Нет case definition — нет новых обязательств. Автосоздания кейсов, изменений +существующих данных, моделей, FK, колонок и миграций нет. Ограничение уникальности +`(partner_program, name)` остаётся прежним. + +## Жизненный цикл + +1. `POST /programs/{programId}/projects/apply/` создаёт draft без case, если он + не передан. Первый вариант автоматически не выбирается. **Все остальные** + required fields по-прежнему обязательны на этом этапе. +2. Если case передан явно, select validation обязательна, в том числе для пустого + значения. Допустимый выбранный вариант сохраняется. +3. До сдачи лидер меняет case обычным PUT program fields, в пределах списка. +4. `POST /programs/partner-program-projects/{programLinkId}/submit/` проверяет + case именно этой связи. Отсутствие/пустое значение даёт 400: + + ```json + {"detail": "Выберите кейс перед сдачей проекта."} + ``` + + Старое значение вне текущих options даёт 400: + + ```json + {"detail": "Выбранный кейс больше недоступен. Выберите актуальный кейс."} + ``` + +5. При отказе `submitted` и `datetime_submitted` не меняются. При успешной сдаче + конкурсного проекта case и остальные field values заблокированы, как раньше. + Manager/expert/staff override не добавлен. Сроки и остальные условия сдачи + сохранены; для программы без case действует прежний сценарий. + +PUT и submit атомарны и блокируют строку связи. Запись case также блокирует +definition; её обычное редактирование блокирует ту же строку. Это защищает от +одновременной сдачи/записи и удаления варианта во время выбора. Повторная проверка +явного case в apply выполняется внутри транзакции перед созданием draft. + +## Стабильность истории и Django Admin + +Обычные `Model.save()` и admin ModelForm используют общую валидацию: + +- добавить вариант или удалить неиспользуемый — можно; +- изменить `label` / `help_text` — можно; +- удалить или переименовать используемый текстовый вариант — нельзя; +- сменить `name="case"` или перенести definition в другую программу при + существующих значениях — нельзя. + +Кейсы по-прежнему создаются существующим inline `PartnerProgramField`, отдельной +Case Admin нет. Стандартный inline formset запрещает удаление использованного +case field. Стандартный экран удаления отдельного field (включая bulk action) +показывает его как protected. Неиспользованный field удалить можно. + +Ограничения v1: это не database trigger. Прямые SQL, `QuerySet.update()`, +`bulk_create()` / `QuerySet.delete()` и удаление всей родительской программы +администратором могут обходить обычную model/form validation. Такие операции +не являются способом редактирования кейсов и требуют отдельной согласованной +миграции данных. Административное ручное редактирование флага `submitted` связи +не заменяет пользовательский submit endpoint. Отдельного действия массового +переименования кейса пока нет: в будущем оно должно атомарно менять options и +все соответствующие значения. Исторически некорректные значения автоматически +не исправляются; submit выдаёт контролируемую ошибку. + +### Неизменённые inline при сдаче через Admin + +Заморозка запрещает **мутацию**, а не повторную validation существующей строки. +Model validation сравнивает `program_project_id`, `field_id`, `value_text` +с сохранённой строкой по pk. Новый объект, перенос, смена поля или текста — +мутация; неизменённая строка может повторно валидироваться после сдачи. +Проверка допустимости самого case value при этом не отключается. + +Учитывается и сохранённый `submitted` родителя, и его несохранённое значение +из текущей admin-формы. Устаревший parent instance, перенос из сданной связи +или снятие checkbox вместе с изменением полей не обходят freeze. + +- `submitted=false → true`, существующие inline без изменений: Save успешен. +- `submitted=true`, inline без изменений и правка несвязанной metadata родителя: + Save допустим. +- Одновременная сдача и изменение/добавление/удаление field value: форма отклонена; + сначала отдельно сохранить финальные поля, затем отдельным Save отметить сдачу. +- Изменение/добавление/удаление после сдачи конкурсной связи: запрещено. + +Для DELETE используется отдельный inline formset: Django не применяет обычные +model errors удаляемой формы, поэтому проверка выполняется на уровне formset и +возвращает контролируемую validation error. Правила одинаковы для generic и case. +Пользовательские `update_program_link_fields()` и `submit_program_project()` +не меняются: API PUT после сдачи по-прежнему отклоняется, даже если значения те же. + +## Канонический link-scoped API + +`GET /programs/partner-program-projects/{programLinkId}/fields/` + +```json +{ + "program_link_id": 700, + "program_id": 12, + "project_id": 55, + "submitted": false, + "fields": [ + { + "id": 5, + "name": "case", + "label": "Выберите задачу", + "field_type": "select", + "is_required": true, + "show_filter": true, + "help_text": null, + "options": ["AI для образования", "Цифровой HR"], + "value": "AI для образования" + } + ] +} +``` + +Нет значения — `value: null`. Definitions упорядочены по pk. Значения другой +связи/программы не подмешиваются. GET требует authentication и ограниченного +read involvement: лидер, участник команды/приглашённый по существующему helper, +staff/superuser, manager/expert **именно программы этой связи**. Одна лишь +публичность проекта доступа к этим полям не даёт. Роли программы B не раскрывают +поля программы A, даже если проект общий. Проверка выполняется через `projects/access.py:has_program_link_read_access()`: +independent project-level access либо program-role links с точным PK запрошенной +связи. Все три исходных helper из production #732 сохраняются без изменений; +scoped singular Project detail и WRITE permissions не заменяются DEV-версией. + +Для лидера GET использует **3 SQL-запроса** независимо от количества полей: +link + program + project одним join; все values; все definitions. Для остальных +ролей добавляются ограниченные EXISTS проверки доступа, не по числу полей. +Регрессионный тест измеряет HTTP GET при 1 и 20 полях. + +`PUT /programs/partner-program-projects/{programLinkId}/fields/` + +```json +[{"field_id": 5, "value_text": "Цифровой HR"}] +``` + +Формат прежний, массив, partial update допустим; ответ 200: +`{"detail": "Значения успешно обновлены"}`. Только лидер соответствующего +Project. Manager/expert/staff не получают право записи. Чужой `field_id`, +повторяющиеся IDs, неверный select, запись в сданную конкурсную связь — 400. +Чужой field ID проверяется в queryset программы до раскрытия его вариантов. +Невалидный элемент отклоняет весь запрос. Anonymous — 401, отсутствие доступа — +403, отсутствующая связь — 404. Поддерживаются GET/PUT, не POST/DELETE. + +## Legacy API + +`PUT /projects/{projectId}/program-fields/` сохраняется: + +- одна связь — тот же атомарный механизм записи; +- нет связей — контролируемая 400; +- несколько — 409, без записи и без выбора первой: + + ```json + {"detail": "Проект связан с несколькими программами. Укажите конкретную связь программы."} + ``` + +Следующий Angular этап должен использовать `programLinkId`, не подставлять +`options[0]` на apply и показывать placeholder «Выберите кейс» до явного выбора. + +## Совместимость фильтров и границы + +`GET /programs/{programId}/filters/` возвращает case через существующее +`show_filter=true`. Manager filter `POST /programs/{programId}/projects/filter/` +и expert filter `POST /rate-project/{programId}` принимают без изменений: + +```json +{"filters": {"case": ["AI для образования", "Цифровой HR"]}} +``` + +Внутри поля сохраняется OR semantics выбранных вариантов. Разрешения rating, +критерии и scoring не изменены. Тесты проверяют A, B, A+B и независимые значения +одного Project в разных программах. + +Analytics по кейсам, отдельная модель кейса, автоматическое исправление старых +данных и frontend — следующие отдельные этапы, не часть этого PR. + +## Границы production foundation + +Это semantic port DEV #728 поверх production #732. Application, Team, Submission, +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 остаются отдельными этапами. + +Перед включением на сервере нужен read-only inventory существующих definitions +с exact `name="case"` и их values: некорректные flags/options и obsolete choices +не исправляются автоматически. Никаких новых schema/data migrations в foundation нет. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index b85d250b..54c78690 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -30,6 +30,11 @@ TeamMember, ) from partner_programs.services import prepare_project_scores_export_data +from partner_programs.forms import ( + ProgramFieldInlineFormSet, + ProgramFieldValueInlineFormSet, +) +from partner_programs.services.case_fields import case_field_has_values @admin.register(Application) @@ -447,6 +452,7 @@ class PartnerProgramMaterialInline(admin.StackedInline): class PartnerProgramFieldInline(admin.TabularInline): model = PartnerProgramField + formset = ProgramFieldInlineFormSet extra = 0 @@ -732,6 +738,7 @@ def has_file(self, obj): class PartnerProgramFieldValueInline(admin.TabularInline): model = PartnerProgramFieldValue + formset = ProgramFieldValueInlineFormSet extra = 0 autocomplete_fields = ("field",) readonly_fields = ("get_display_value",) @@ -760,6 +767,16 @@ class PartnerProgramProjectAdmin(admin.ModelAdmin): @admin.register(PartnerProgramField) class PartnerProgramFieldAdmin(admin.ModelAdmin): + def get_deleted_objects(self, objs, request): + """Use Django's standard protected deletion UI for single and bulk actions.""" + deleted, counts, permissions_needed, protected = super().get_deleted_objects( + objs, request + ) + for obj in objs: + if case_field_has_values(obj): + protected.append(f"Используемое поле кейса: {obj}") + return deleted, counts, permissions_needed, protected + list_display = ( "id", "partner_program", diff --git a/partner_programs/constants.py b/partner_programs/constants.py index 322c7526..73a894eb 100644 --- a/partner_programs/constants.py +++ b/partner_programs/constants.py @@ -1,3 +1,6 @@ +PROGRAM_CASE_FIELD_NAME = "case" + + DEFAULT_DATA_SCHEMA = { "field": {"type": "text"}, } diff --git a/partner_programs/forms.py b/partner_programs/forms.py new file mode 100644 index 00000000..1b5fd163 --- /dev/null +++ b/partner_programs/forms.py @@ -0,0 +1,32 @@ +"""Admin integration uses ordinary ModelForm validation, not a separate Case admin.""" + +from django.core.exceptions import ValidationError +from django.forms.models import BaseInlineFormSet + +from partner_programs.services.case_fields import case_field_has_values +from partner_programs.services.field_values import ( + FIELD_VALUES_FROZEN_MESSAGE, + program_field_values_are_frozen, +) + + +class ProgramFieldInlineFormSet(BaseInlineFormSet): + """Do not cascade-delete used case values through a definition inline.""" + + def clean(self): + super().clean() + for form in self.deleted_forms: + if case_field_has_values(form.instance): + raise ValidationError( + "Нельзя удалить системное поле с выбранными кейсами." + ) + + +class ProgramFieldValueInlineFormSet(BaseInlineFormSet): + """Admin skips model errors on DELETE forms, so enforce frozen deletion here.""" + + def clean(self): + super().clean() + if any(form.instance.pk for form in self.deleted_forms): + if program_field_values_are_frozen(self.instance): + raise ValidationError(FIELD_VALUES_FROZEN_MESSAGE) diff --git a/partner_programs/models.py b/partner_programs/models.py index 6fb16e32..8a4939b1 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.db import models +from django.db import models, transaction from django.utils import timezone from files.models import UserFile @@ -1333,6 +1333,31 @@ def get_options_list(self) -> list[str]: opts = self.options.split("|") if self.options else [] return [opt.strip() for opt in opts if opt.strip()] + def clean(self): + """ModelForm/admin and normal model writes share the reserved case rules.""" + super().clean() + from partner_programs.services.case_fields import ( + validate_program_case_configuration, + ) + + validate_program_case_configuration(self) + + def save(self, *args, **kwargs): + """Serialize case definition edits with case value writes; no schema changes.""" + from partner_programs.services.case_fields import is_program_case_field + + with transaction.atomic(): + previous = ( + type(self).objects.select_for_update().filter(pk=self.pk).first() + if self.pk + else None + ) + if is_program_case_field(self) or ( + previous and is_program_case_field(previous) + ): + self.full_clean() + return super().save(*args, **kwargs) + class PartnerProgramFieldValue(models.Model): program_project = models.ForeignKey( @@ -1359,14 +1384,34 @@ def get_value(self): return self.value_text def clean(self): - if ( - self.program_project.partner_program.is_competitive - and self.program_project.submitted - ): - raise ValidationError( - "Нельзя изменять значения полей программы после сдачи проекта на проверку." - ) + from partner_programs.services.field_values import validate_field_value_mutation + + validate_field_value_mutation(self) + from partner_programs.services.case_fields import ( + is_program_case_field, + validate_case_value, + ) + + if is_program_case_field(self.field): + if self.field.partner_program_id != self.program_project.partner_program_id: + raise ValidationError("Поле кейса не относится к программе этой связи.") + validate_case_value(self.field, self.value_text) def save(self, *args, **kwargs): + from partner_programs.services.case_fields import is_program_case_field + + if is_program_case_field(self.field): + # Admin/model writes obey the same link -> definition lock order as PUT. + with transaction.atomic(): + # Keep the parent-form instance: its unsaved submitted=True matters. + # Validation also checks DB state, under this same link lock. + PartnerProgramProject.objects.select_for_update().get( + pk=self.program_project_id + ) + self.field = PartnerProgramField.objects.select_for_update().get( + pk=self.field_id + ) + self.full_clean() + return super().save(*args, **kwargs) self.full_clean() super().save(*args, **kwargs) diff --git a/partner_programs/serializers/fields.py b/partner_programs/serializers/fields.py index 5c4ec436..443c246b 100644 --- a/partner_programs/serializers/fields.py +++ b/partner_programs/serializers/fields.py @@ -5,8 +5,19 @@ from partner_programs.models import PartnerProgramField +class ProgramFieldReference(serializers.PrimaryKeyRelatedField): + """Resolve input ids within the URL program before revealing field validation.""" + + def get_queryset(self): + queryset = super().get_queryset() + program = self.context.get("program") + return ( + queryset.filter(partner_program=program) if program is not None else queryset + ) + + class PartnerProgramFieldValueUpdateSerializer(serializers.Serializer): - field_id = serializers.PrimaryKeyRelatedField( + field_id = ProgramFieldReference( queryset=PartnerProgramField.objects.all(), source="field", ) diff --git a/partner_programs/services/case_fields.py b/partner_programs/services/case_fields.py new file mode 100644 index 00000000..7888c6e1 --- /dev/null +++ b/partner_programs/services/case_fields.py @@ -0,0 +1,89 @@ +"""Reserved case semantics on existing program fields, never on global Project.""" + +from django.core.exceptions import ValidationError + +from partner_programs.constants import PROGRAM_CASE_FIELD_NAME + + +def is_program_case_field(field): + """Only the exact service name identifies a case; label/type are not heuristics.""" + return field.name == PROGRAM_CASE_FIELD_NAME + + +def get_program_case_field(program, *, for_update=False): + """Return the optional definition; absence does not enable case requirements.""" + fields = program.fields.filter(name=PROGRAM_CASE_FIELD_NAME) + if for_update: + fields = fields.select_for_update() + return fields.first() + + +def case_field_has_values(field): + """Use persisted identity, including when an admin form edits name before deletion.""" + if not field.pk: + return False + return ( + type(field) + .objects.filter(pk=field.pk, name=PROGRAM_CASE_FIELD_NAME, values__isnull=False) + .exists() + ) + + +def validate_program_case_configuration(field): + """Validate select-only config and preserve every used exact textual option.""" + previous = type(field).objects.filter(pk=field.pk).first() if field.pk else None + if previous and is_program_case_field(previous) and previous.values.exists(): + if field.name != previous.name: + raise ValidationError( + {"name": "Нельзя переименовать системное поле с выбранными кейсами."} + ) + if field.partner_program_id != previous.partner_program_id: + raise ValidationError( + {"partner_program": "Нельзя переносить поле с выбранными кейсами."} + ) + + if not is_program_case_field(field): + return + errors = {} + if field.field_type != "select": + errors["field_type"] = "Системный кейс должен быть полем select." + if not field.is_required: + errors["is_required"] = "Кейс обязателен перед сдачей проекта." + if not field.show_filter: + errors["show_filter"] = "Системный кейс должен быть доступен для фильтрации." + options = [value.strip() for value in (field.options or "").split("|")] + if not all(options): + errors["options"] = "Укажите хотя бы один кейс без пустых вариантов." + elif len({value.casefold() for value in options}) != len(options): + errors[ + "options" + ] = "Варианты кейсов не должны повторяться (без учёта регистра и внешних пробелов)." + if field.pk: + used = set(field.values.values_list("value_text", flat=True).distinct()) + if used - set(options): + errors["options"] = "Нельзя удалить или переименовать используемый кейс." + if errors: + raise ValidationError(errors) + + +def validate_case_value(field, value): + """Validate an explicit choice against the current exact options, without guessing.""" + if value is None or not str(value).strip(): + raise ValidationError("Выберите кейс перед сдачей проекта.") + if value not in field.get_options_list(): + raise ValidationError( + "Выбранный кейс больше недоступен. Выберите актуальный кейс." + ) + + +def validate_case_before_submission(program_project): + """Lock config inside the caller's transaction; validate this link only.""" + field = get_program_case_field(program_project.partner_program, for_update=True) + if field is None: + return + value = ( + program_project.field_values.filter(field=field) + .values_list("value_text", flat=True) + .first() + ) + validate_case_value(field, value) diff --git a/partner_programs/services/field_values.py b/partner_programs/services/field_values.py new file mode 100644 index 00000000..3b31a873 --- /dev/null +++ b/partner_programs/services/field_values.py @@ -0,0 +1,61 @@ +"""Freeze mutations, not revalidation of unchanged model/admin inline values.""" + +from django.core.exceptions import ValidationError + +from partner_programs.models import PartnerProgramProject + + +FIELD_VALUES_FROZEN_MESSAGE = ( + "Нельзя изменять значения полей программы после сдачи проекта на проверку." +) + + +def program_field_values_are_frozen(program_project): + """Either persisted submission or a pending parent-form submission freezes edits. + + The admin validates inlines before saving the parent, so DB state alone misses + simultaneous submit/edit; the in-memory flag alone permits unsubmit/edit or + writes using a stale parent instance. + """ + pending_submission = ( + program_project.submitted + and program_project.partner_program_id + and program_project.partner_program.is_competitive + ) + return ( + bool(pending_submission) + or PartnerProgramProject.objects.filter( + pk=program_project.pk, + submitted=True, + partner_program__is_competitive=True, + ).exists() + ) + + +def validate_field_value_mutation(value): + """Compare persisted identity/content; new, moved or changed values are mutations.""" + fields = ("program_project_id", "field_id", "value_text") + persisted = ( + type(value).objects.filter(pk=value.pk).values(*fields).first() + if value.pk is not None + else None + ) + if ( + not value._state.adding + and persisted is not None + and all(persisted[name] == getattr(value, name) for name in fields) + ): + return + + # Moving a persisted value out of a frozen link must not bypass its freeze. + frozen_source = ( + persisted is not None + and persisted["program_project_id"] != value.program_project_id + and PartnerProgramProject.objects.filter( + pk=persisted["program_project_id"], + submitted=True, + partner_program__is_competitive=True, + ).exists() + ) + if frozen_source or program_field_values_are_frozen(value.program_project): + raise ValidationError(FIELD_VALUES_FROZEN_MESSAGE) diff --git a/partner_programs/services/project_apply.py b/partner_programs/services/project_apply.py index 747fd440..351a7aef 100644 --- a/partner_programs/services/project_apply.py +++ b/partner_programs/services/project_apply.py @@ -1,15 +1,21 @@ from dataclasses import dataclass from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from rest_framework.exceptions import PermissionDenied, ValidationError +from partner_programs.constants import PROGRAM_CASE_FIELD_NAME from partner_programs.models import ( PartnerProgram, PartnerProgramFieldValue, PartnerProgramProject, PartnerProgramUserProfile, ) +from partner_programs.services.case_fields import ( + get_program_case_field, + validate_case_value, +) from projects.models import Project User = get_user_model() @@ -55,7 +61,9 @@ def _validate_unique_program_fields(values_data: list[dict]) -> None: seen_field_ids.add(field_id) if duplicate_ids: raise ValidationError( - {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} + { + "program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}" + } ) @@ -64,8 +72,11 @@ def _validate_required_program_fields( program: PartnerProgram, values_data: list[dict], ) -> None: + """Only reserved case is deferred to submission; generic required fields remain.""" required_fields = list( - program.fields.filter(is_required=True).values("id", "label") + program.fields.filter(is_required=True) + .exclude(name=PROGRAM_CASE_FIELD_NAME) + .values("id", "label") ) provided_field_ids = {item["field"].id for item in values_data} missing_required = [ @@ -75,7 +86,9 @@ def _validate_required_program_fields( ] if missing_required: raise ValidationError( - {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} + { + "program_field_values": f"Не заполнены обязательные поля: {missing_required}" + } ) @@ -111,7 +124,7 @@ def apply_project_to_program( if existing_link: raise ProgramProjectAlreadyApplied(existing_link) - serializer = serializer_class(data=data) + serializer = serializer_class(data=data, context={"program": program}) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data @@ -123,6 +136,16 @@ def apply_project_to_program( _validate_program_field_ownership(program=program, values_data=values_data) with transaction.atomic(): + # Missing case is valid at draft creation; an explicit choice must stay valid + # even if the definition changed between input validation and this transaction. + case_field = get_program_case_field(program, for_update=True) + if case_field is not None: + for item in values_data: + if item["field"].pk == case_field.pk: + try: + validate_case_value(case_field, item.get("value_text")) + except DjangoValidationError as error: + raise ValidationError({"program_field_values": error.messages}) project = Project.objects.create( leader=user, draft=True, diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py new file mode 100644 index 00000000..ef6bf23e --- /dev/null +++ b/partner_programs/services/project_fields.py @@ -0,0 +1,120 @@ +"""Link-scoped program field access and atomic edits/submission.""" + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError + +from partner_programs.models import PartnerProgramFieldValue, PartnerProgramProject +from partner_programs.serializers import ( + PartnerProgramFieldSerializer, + PartnerProgramFieldValueUpdateSerializer, +) +from partner_programs.services.case_fields import validate_case_before_submission +from projects.models import Project +from projects.access import has_program_link_read_access + + +class AmbiguousProgramLink(APIException): + status_code = 409 + default_detail = ( + "Проект связан с несколькими программами. Укажите конкретную связь программы." + ) + default_code = "ambiguous_program_link" + + +def get_program_link(link_id, *, for_update=False): + """Resolve exactly one Project × Program context; optionally lock the link row.""" + links = PartnerProgramProject.objects.select_related("project", "partner_program") + if for_update: + links = links.select_for_update(of=("self",)) + return get_object_or_404(links, pk=link_id) + + +def require_link_leader(link, user): + """No manager, expert or staff edit override: only the actual project leader.""" + if link.project.leader_id != user.pk: + raise PermissionDenied("Вы не являетесь лидером этого проекта") + + +def resolve_legacy_program_link(project_id, user): + """Legacy writes require exactly one link; never silently choose the first.""" + project = get_object_or_404(Project, pk=project_id) + if project.leader_id != user.pk: + raise PermissionDenied("Вы не являетесь лидером этого проекта") + links = list(project.program_links.order_by("pk").values_list("pk", flat=True)[:2]) + if not links: + raise ValidationError({"detail": "Проект не привязан ни к одной программе"}) + if len(links) > 1: + raise AmbiguousProgramLink() + return links[0] + + +def program_link_fields(link_id, user): + """Restricted read, current-program roles only; definitions/values are bulk loaded.""" + link = get_program_link(link_id) + if not has_program_link_read_access(user, link): + raise PermissionDenied("У вас нет доступа к полям этой программы проекта.") + values = dict(link.field_values.values_list("field_id", "value_text")) + fields = PartnerProgramFieldSerializer( + link.partner_program.fields.order_by("pk"), many=True + ).data + return { + "program_link_id": link.pk, + "program_id": link.partner_program_id, + "project_id": link.project_id, + "submitted": link.submitted, + "fields": [{**field, "value": values.get(field["id"])} for field in fields], + } + + +@transaction.atomic +def update_program_link_fields(link_id, user, data): + """Atomic partial update; link lock also serializes this write with submission.""" + link = get_program_link(link_id, for_update=True) + require_link_leader(link, user) + if link.partner_program.is_competitive and link.submitted: + raise ValidationError( + { + "detail": "Нельзя изменять значения полей программы после сдачи проекта на проверку." + } + ) + # Lock definitions before validation, so removing an option cannot race a choice. + list(link.partner_program.fields.select_for_update().order_by("pk")) + serializer = PartnerProgramFieldValueUpdateSerializer( + data=data, many=True, context={"program": link.partner_program} + ) + serializer.is_valid(raise_exception=True) + field_ids = [item["field"].pk for item in serializer.validated_data] + if len(field_ids) != len(set(field_ids)): + raise ValidationError({"detail": "В запросе не должны повторяться field_id."}) + try: + for item in serializer.validated_data: + PartnerProgramFieldValue.objects.update_or_create( + program_project=link, + field=item["field"], + defaults={"value_text": item.get("value_text")}, + ) + except DjangoValidationError as error: + raise ValidationError({"detail": error.messages}) + + +@transaction.atomic +def submit_program_project(link_id, user): + """Freeze a link only after current case validation, under the same lock as PUT.""" + link = get_program_link(link_id, for_update=True) + require_link_leader(link, user) + if not link.partner_program.is_competitive: + raise ValidationError({"detail": "Программа не является конкурсной."}) + if link.submitted: + raise ValidationError({"detail": "Проект уже был сдан на проверку."}) + if not link.partner_program.is_project_submission_open(): + raise ValidationError({"detail": "Срок подачи проектов в программу завершён."}) + try: + validate_case_before_submission(link) + except DjangoValidationError as error: + raise ValidationError({"detail": error.messages[0]}) + link.submitted = True + link.datetime_submitted = timezone.now() + link.save(update_fields=["submitted", "datetime_submitted", "datetime_updated"]) diff --git a/partner_programs/tests/test_case_fields.py b/partner_programs/tests/test_case_fields.py new file mode 100644 index 00000000..2dda39cc --- /dev/null +++ b/partner_programs/tests/test_case_fields.py @@ -0,0 +1,221 @@ +"""Reserved definition rules, history preservation and standard admin validation.""" + +from django.contrib import admin +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.forms import inlineformset_factory, modelform_factory +from django.test import RequestFactory, TestCase + +from partner_programs.admin import PartnerProgramFieldAdmin +from partner_programs.forms import ProgramFieldInlineFormSet +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, +) +from partner_programs.services.case_fields import is_program_case_field +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_user, +) + + +def create_case_field(program, **overrides): + """Create a real definition; no auto-generated case in generic test programs.""" + values = dict( + name="case", + label="Выберите задачу", + field_type="select", + is_required=True, + show_filter=True, + options=["A", "B", "C"], + ) + values.update(overrides) + return create_program_field(program, **values) + + +class ProgramCaseConfigurationTests(TestCase): + def setUp(self): + self.program = create_partner_program() + + def test_valid_case_with_custom_label_and_trimmed_options(self): + field = create_case_field(self.program, options=[" A ", "B"]) + self.assertTrue(is_program_case_field(field)) + self.assertEqual(field.get_options_list(), ["A", "B"]) + self.assertEqual(field.label, "Выберите задачу") + + def test_only_exact_name_is_reserved_and_generic_fields_are_unchanged(self): + for name in ("Кейс", "case_name", "track", "Case", "CASE"): + with self.subTest(name=name): + field = create_program_field(self.program, name=name, label="Кейс") + field.full_clean() + self.assertFalse(is_program_case_field(field)) + + def test_non_select_case_types_are_rejected(self): + for kind in ("text", "textarea", "checkbox", "radio", "file"): + with self.subTest(kind=kind), self.assertRaises(ValidationError) as error: + create_case_field(self.program, field_type=kind) + self.assertIn("field_type", error.exception.message_dict) + + def test_required_and_filter_flags_cannot_be_disabled(self): + for flag in ("is_required", "show_filter"): + with self.subTest(flag=flag), self.assertRaises(ValidationError) as error: + create_case_field(self.program, **{flag: False}) + self.assertIn(flag, error.exception.message_dict) + + def test_empty_and_duplicate_options_are_rejected(self): + for options in ([], [""], ["A", ""], [" ", "A"], ["A", "A"], ["AI", " ai "]): + with self.subTest(options=options), self.assertRaises( + ValidationError + ) as error: + create_case_field(self.program, options=options) + self.assertIn("options", error.exception.message_dict) + + def test_database_still_prevents_second_reserved_name(self): + field = create_case_field(self.program) + duplicate = PartnerProgramField( + partner_program=self.program, + name=field.name, + label="Duplicate", + field_type="select", + is_required=True, + show_filter=True, + options="A", + ) + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgramField.objects.bulk_create([duplicate]) + + def test_program_has_no_automatic_case(self): + self.assertFalse(self.program.fields.exists()) + + +class ProgramCaseHistoryTests(TestCase): + def setUp(self): + self.program = create_partner_program(is_competitive=True) + self.field = create_case_field(self.program) + self.link = create_program_project(self.program) + self.value = PartnerProgramFieldValue.objects.create( + program_project=self.link, field=self.field, value_text="B" + ) + + def test_add_option_and_remove_unused_option(self): + for options in ("A|B|C|D", "B|C"): + self.field.options = options + self.field.save() + self.value.refresh_from_db() + self.assertEqual(self.value.value_text, "B") + + def test_remove_or_rename_used_option_is_rejected_on_save(self): + for options in ("A|C", "A|B renamed|C", "A|b|C"): + self.field.options = options + with self.subTest(options=options), self.assertRaises(ValidationError): + self.field.save() + self.field.refresh_from_db() + self.assertEqual(self.field.options, "A|B|C") + + def test_label_and_help_text_can_change_without_changing_values(self): + self.field.label = "Новая подпись" + self.field.help_text = "Пояснение" + self.field.save() + self.value.refresh_from_db() + self.assertEqual(self.value.value_text, "B") + + def test_used_case_name_and_program_cannot_change(self): + self.field.name = "track" + with self.assertRaises(ValidationError): + self.field.save() + self.field.refresh_from_db() + self.field.partner_program = create_partner_program() + with self.assertRaises(ValidationError): + self.field.save() + + def test_generic_option_edit_retains_existing_behavior(self): + field = create_program_field( + self.program, name="track", field_type="select", options=["A"] + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link, field=field, value_text="A" + ) + field.options = "B" + field.full_clean() + field.save() + + def test_direct_case_value_save_validates_choice_and_program(self): + self.value.value_text = "unknown" + with self.assertRaises(ValidationError): + self.value.save() + other = create_program_project(create_partner_program()) + with self.assertRaises(ValidationError): + PartnerProgramFieldValue.objects.create( + program_project=other, field=self.field, value_text="A" + ) + + def test_stale_link_instance_cannot_edit_case_after_submission(self): + type(self.link).objects.filter(pk=self.link.pk).update(submitted=True) + self.value.value_text = "C" + with self.assertRaises(ValidationError): + self.value.save() + self.value.refresh_from_db() + self.assertEqual(self.value.value_text, "B") + + def test_admin_modelform_uses_config_and_history_rules(self): + form_class = modelform_factory(PartnerProgramField, fields="__all__") + data = dict( + partner_program=self.program.pk, + name="case", + label="Case", + field_type="select", + is_required=True, + show_filter=True, + options="A|C", + ) + form = form_class(data=data, instance=self.field) + self.assertFalse(form.is_valid()) + self.assertIn("options", form.errors) + data.update(options="A|B|C", field_type="text") + self.assertIn("field_type", form_class(data=data, instance=self.field).errors) + + def test_inline_cannot_delete_used_case_but_can_delete_unused_generic_field(self): + factory = inlineformset_factory( + PartnerProgram, + PartnerProgramField, + formset=ProgramFieldInlineFormSet, + fields="__all__", + can_delete=True, + ) + + def formset_for(field): + return factory( + instance=self.program, + prefix="fields", + data={ + "fields-TOTAL_FORMS": "1", + "fields-INITIAL_FORMS": "1", + "fields-0-id": field.pk, + "fields-0-partner_program": self.program.pk, + "fields-0-name": field.name, + "fields-0-label": field.label, + "fields-0-field_type": field.field_type, + "fields-0-is_required": True, + "fields-0-show_filter": True, + "fields-0-options": field.options, + "fields-0-DELETE": True, + }, + ) + + used = formset_for(self.field) + self.assertFalse(used.is_valid()) + self.assertIn("Нельзя удалить", str(used.non_form_errors())) + unused = create_program_field(self.program) + self.assertTrue(formset_for(unused).is_valid()) + + def test_standard_admin_delete_confirmation_protects_used_case(self): + request = RequestFactory().get("/admin/") + request.user = create_user(is_staff=True, is_superuser=True) + model_admin = PartnerProgramFieldAdmin(PartnerProgramField, admin.site) + protected = model_admin.get_deleted_objects([self.field], request)[3] + self.assertTrue(protected) + unused = create_case_field(create_partner_program()) + self.assertFalse(model_admin.get_deleted_objects([unused], request)[3]) diff --git a/partner_programs/tests/test_case_filters_api.py b/partner_programs/tests/test_case_filters_api.py new file mode 100644 index 00000000..23f4fb3a --- /dev/null +++ b/partner_programs/tests/test_case_filters_api.py @@ -0,0 +1,77 @@ +"""Reserved case reuses the existing schema/manager/expert filters unchanged.""" + +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field +from project_rates.tests.helpers import create_rate_expert + + +class ProgramCaseFilterAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_partner_program() + self.field = create_case_field(self.program) + self.manager = create_user() + self.program.managers.add(self.manager) + self.expert = create_rate_expert(program=self.program) + self.projects = [create_project(is_public=True), create_project(is_public=True)] + for project, value in zip(self.projects, ["A", "B"]): + link = create_program_project(self.program, project=project) + PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text=value + ) + # Same Project has a different case in another program; never mix its values. + other = create_partner_program() + other_field = create_case_field(other) + other_link = create_program_project(other, project=self.projects[0]) + PartnerProgramFieldValue.objects.create( + program_project=other_link, field=other_field, value_text="B" + ) + + def test_expert_and_manager_get_identical_filterable_case_schema(self): + self.client.force_authenticate(self.manager) + manager = self.client.get(f"/programs/{self.program.pk}/filters/") + self.client.force_authenticate(self.expert) + expert = self.client.get(f"/programs/{self.program.pk}/filters/") + self.assertEqual(manager.status_code, 200) + self.assertEqual(expert.status_code, 200) + self.assertEqual(manager.data, expert.data) + self.assertIn("case", str(expert.data)) + self.assertEqual( + self.client.post( + f"/programs/{self.program.pk}/filters/", {}, format="json" + ).status_code, + 405, + ) + + def assert_filtered_projects(self, user, url): + self.client.force_authenticate(user) + for values, expected in ( + (["A"], [self.projects[0].pk]), + (["B"], [self.projects[1].pk]), + (["A", "B"], [p.pk for p in self.projects]), + ): + with self.subTest(values=values): + response = self.client.post( + url, {"filters": {"case": values}}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertCountEqual( + [item["id"] for item in response.data["results"]], expected + ) + + def test_manager_case_filter_returns_only_selected_program_cases(self): + self.assert_filtered_projects( + self.manager, f"/programs/{self.program.pk}/projects/filter/" + ) + + def test_expert_rating_case_filter_preserves_existing_or_semantics(self): + self.assert_filtered_projects(self.expert, f"/rate-project/{self.program.pk}") diff --git a/partner_programs/tests/test_case_lifecycle_api.py b/partner_programs/tests/test_case_lifecycle_api.py new file mode 100644 index 00000000..fa348d50 --- /dev/null +++ b/partner_programs/tests/test_case_lifecycle_api.py @@ -0,0 +1,170 @@ +"""Case is optional on draft apply, mandatory on submit, and scoped to its link.""" + +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue, PartnerProgramProject +from partner_programs.services.project_apply import _validate_program_field_ownership +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, + project_apply_payload, +) +from partner_programs.tests.test_case_fields import create_case_field + + +class ProgramCaseLifecycleAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user() + self.client.force_authenticate(self.user) + self.program = create_partner_program(is_competitive=True) + create_program_member(self.program, user=self.user) + self.field = create_case_field(self.program) + self.apply_url = f"/programs/{self.program.pk}/projects/apply/" + + def apply(self, values=None): + return self.client.post( + self.apply_url, + project_apply_payload(program_field_values=values), + format="json", + ) + + def link(self): + return create_program_project( + self.program, project=create_project(leader=self.user, draft=True) + ) + + def submit(self, link): + return self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + + def assert_not_submitted(self, link, expected_date=None): + link.refresh_from_db() + self.assertFalse(link.submitted) + self.assertEqual(link.datetime_submitted, expected_date) + + def test_draft_apply_without_case_does_not_choose_first_option(self): + response = self.apply() + self.assertEqual(response.status_code, 201) + link = PartnerProgramProject.objects.get(pk=response.data["program_link_id"]) + self.assertTrue(link.project.draft) + self.assertFalse(link.submitted) + self.assertFalse(link.field_values.exists()) + + def test_apply_with_explicit_valid_case_preserves_choice(self): + response = self.apply([{"field_id": self.field.pk, "value_text": "B"}]) + self.assertEqual(response.status_code, 201) + self.assertEqual(PartnerProgramFieldValue.objects.get().value_text, "B") + + def test_apply_explicit_invalid_case_does_not_create_project(self): + for value in (None, "", "unknown"): + self.assertEqual( + self.apply( + [{"field_id": self.field.pk, "value_text": value}] + ).status_code, + 400, + ) + self.assertFalse(PartnerProgramProject.objects.exists()) + + def test_apply_revalidates_choice_after_options_change_before_transaction(self): + def change_options(**kwargs): + _validate_program_field_ownership(**kwargs) + self.field.options = "A|C" + self.field.save() + + with patch( + "partner_programs.services.project_apply._validate_program_field_ownership", + side_effect=change_options, + ): + response = self.apply([{"field_id": self.field.pk, "value_text": "B"}]) + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramProject.objects.exists()) + self.assertFalse(PartnerProgramFieldValue.objects.exists()) + + def test_other_required_fields_remain_required_on_apply(self): + field = create_program_field(self.program, name="track", is_required=True) + self.assertEqual(self.apply().status_code, 400) + self.assertEqual( + self.apply([{"field_id": field.pk, "value_text": "answer"}]).status_code, 201 + ) + + def test_submit_missing_case_has_controlled_error_without_date_change(self): + link = self.link() + original = timezone.now() - timezone.timedelta(days=1) + link.datetime_submitted = original + link.save() + response = self.submit(link) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "Выберите кейс перед сдачей проекта.") + self.assert_not_submitted(link, original) + + def test_submit_empty_legacy_value_is_rejected_atomically(self): + link = self.link() + value = PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text="A" + ) + for text in ("", " "): + PartnerProgramFieldValue.objects.filter(pk=value.pk).update(value_text=text) + response = self.submit(link) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["detail"], "Выберите кейс перед сдачей проекта." + ) + self.assert_not_submitted(link) + + def test_submit_stale_legacy_value_is_rejected_atomically(self): + link = self.link() + value = PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text="A" + ) + PartnerProgramFieldValue.objects.filter(pk=value.pk).update( + value_text="deleted old option" + ) + response = self.submit(link) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["detail"], + "Выбранный кейс больше недоступен. Выберите актуальный кейс.", + ) + self.assert_not_submitted(link) + + def test_submit_valid_case_freezes_fields(self): + link = self.link() + PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text="B" + ) + self.assertEqual(self.submit(link).status_code, 200) + link.refresh_from_db() + self.assertTrue(link.submitted) + self.assertIsNotNone(link.datetime_submitted) + response = self.client.put( + f"/programs/partner-program-projects/{link.pk}/fields/", + [{"field_id": self.field.pk, "value_text": "C"}], + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(link.field_values.get().value_text, "B") + + def test_other_program_case_does_not_satisfy_current_link_submission(self): + link = self.link() + program_b = create_partner_program(is_competitive=True) + field_b = create_case_field(program_b, options=["Other"]) + link_b = create_program_project(program_b, project=link.project) + PartnerProgramFieldValue.objects.create( + program_project=link_b, field=field_b, value_text="Other" + ) + self.assertEqual(self.submit(link).status_code, 400) + self.assert_not_submitted(link) + self.assertEqual(self.submit(link_b).status_code, 200) + + def test_no_case_preserves_previous_submission_behavior(self): + self.field.delete() + link = self.link() + self.assertEqual(self.submit(link).status_code, 200) diff --git a/partner_programs/tests/test_case_locking.py b/partner_programs/tests/test_case_locking.py new file mode 100644 index 00000000..e14161de --- /dev/null +++ b/partner_programs/tests/test_case_locking.py @@ -0,0 +1,138 @@ +"""Real PostgreSQL connections verify locks, not SQLite's no-op FOR UPDATE.""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from unittest import skipUnless +from unittest.mock import patch + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import OperationalError, connection, connections, transaction +from django.test import TransactionTestCase +from rest_framework.exceptions import ValidationError + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from partner_programs.services.case_fields import validate_case_before_submission +from partner_programs.services.project_fields import ( + submit_program_project, + update_program_link_fields, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field + + +@skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks") +class ProgramCaseLockingTests(TransactionTestCase): + def setUp(self): + self.user = create_user() + self.program = create_partner_program(is_competitive=True) + self.link = create_program_project( + self.program, project=create_project(leader=self.user) + ) + self.field = create_case_field(self.program) + self.value = PartnerProgramFieldValue.objects.create( + program_project=self.link, field=self.field, value_text="B" + ) + + def run_while_locked(self, worker, concurrent_write): + """Worker signals after acquiring locks; competing connection must time out.""" + entered, release = Event(), Event() + + def run(): + try: + worker(entered, release) + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(run) + try: + self.assertTrue(entered.wait(10), "Worker did not reach locked state") + with self.assertRaises(OperationalError), transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET LOCAL lock_timeout = '200ms'") + concurrent_write() + finally: + release.set() + future.result(timeout=10) + + def test_submit_blocks_concurrent_put_then_rejects_edit_after_commit(self): + def worker(entered, release): + def validated(link): + validate_case_before_submission(link) + entered.set() + if not release.wait(10): + raise AssertionError("Lock release timed out") + + with patch( + "partner_programs.services.project_fields.validate_case_before_submission", + side_effect=validated, + ): + submit_program_project(self.link.pk, self.user) + + def edit(): + update_program_link_fields( + self.link.pk, self.user, [{"field_id": self.field.pk, "value_text": "C"}] + ) + + self.run_while_locked(worker, edit) + with self.assertRaises(ValidationError): + edit() + self.value.refresh_from_db() + self.link.refresh_from_db() + self.assertEqual(self.value.value_text, "B") + self.assertTrue(self.link.submitted) + + def test_case_choice_blocks_option_removal_then_history_validation_rejects_it(self): + def worker(entered, release): + value = PartnerProgramFieldValue.objects.get(pk=self.value.pk) + value.value_text = "C" + original_clean = value.clean + + def validated(): + original_clean() + entered.set() + if not release.wait(10): + raise AssertionError("Lock release timed out") + + value.clean = validated + value.save() + + def remove_option(): + self.field.options = "A|B" + self.field.save() + + self.run_while_locked(worker, remove_option) + with self.assertRaises(DjangoValidationError): + remove_option() + self.value.refresh_from_db() + self.field.refresh_from_db() + self.assertEqual(self.value.value_text, "C") + self.assertIn("C", self.field.get_options_list()) + + def test_option_removal_blocks_selection_then_current_options_are_revalidated(self): + def worker(entered, release): + with transaction.atomic(): + field = PartnerProgramField.objects.get(pk=self.field.pk) + field.options = "A|B" + field.save() + entered.set() + if not release.wait(10): + raise AssertionError("Lock release timed out") + + def select_removed_option(): + update_program_link_fields( + self.link.pk, self.user, [{"field_id": self.field.pk, "value_text": "C"}] + ) + + self.run_while_locked(worker, select_removed_option) + with self.assertRaises(ValidationError): + select_removed_option() + self.value.refresh_from_db() + self.field.refresh_from_db() + self.assertEqual(self.value.value_text, "B") + self.assertEqual(self.field.get_options_list(), ["A", "B"]) diff --git a/partner_programs/tests/test_field_value_admin.py b/partner_programs/tests/test_field_value_admin.py new file mode 100644 index 00000000..77c6d84a --- /dev/null +++ b/partner_programs/tests/test_field_value_admin.py @@ -0,0 +1,194 @@ +"""Real parent change POST + inline formset, for generic and reserved case values.""" + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field + + +FREEZE_MESSAGE = ( + "Нельзя изменять значения полей программы после сдачи проекта на проверку." +) + + +class FieldValueAdminFlow: + """The same admin freeze rules must apply to generic and case presentations.""" + + is_case = False + + def setUp(self): + self.user = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(self.user) + self.program = create_partner_program(is_competitive=True) + self.link = create_program_project(self.program) + self.field = ( + create_case_field(self.program, options=["rgdftb", "A", "B"]) + if self.is_case + else create_program_field(self.program) + ) + self.value = PartnerProgramFieldValue.objects.create( + program_project=self.link, field=self.field, value_text="rgdftb" + ) + self.url = reverse( + "admin:partner_programs_partnerprogramproject_change", args=[self.link.pk] + ) + page = self.client.get(self.url) + self.assertEqual(page.status_code, 200, page.get("Location")) + self.prefix = page.context["inline_admin_formsets"][0].formset.prefix + + def payload(self, *, submitted=True, value="rgdftb"): + data = { + "partner_program": self.program.pk, + "project": self.link.project_id, + "datetime_submitted_0": "", + "datetime_submitted_1": "", + f"{self.prefix}-TOTAL_FORMS": "1", + f"{self.prefix}-INITIAL_FORMS": "1", + f"{self.prefix}-0-id": self.value.pk, + f"{self.prefix}-0-program_project": self.link.pk, + f"{self.prefix}-0-field": self.field.pk, + f"{self.prefix}-0-value_text": value, + "_save": "Сохранить", + } + if submitted: + data["submitted"] = "on" + return data + + def freeze(self): + self.link.submitted = True + self.link.save(update_fields=["submitted"]) + + def assert_rejected(self, data, *, submitted): + expected_value = self.value.value_text + response = self.client.post(self.url, data) + self.assertContains(response, FREEZE_MESSAGE, status_code=200) + self.link.refresh_from_db() + self.value.refresh_from_db() + self.assertEqual(self.link.submitted, submitted) + self.assertIsNone(self.link.datetime_submitted) + self.assertEqual(self.value.value_text, expected_value) + self.assertEqual(self.value.field_id, self.field.pk) + self.assertEqual(self.link.field_values.count(), 1) + + def test_submit_with_unchanged_inline_redirects_and_preserves_row(self): + response = self.client.post(self.url, self.payload()) + self.assertEqual(response.status_code, 302) + self.link.refresh_from_db() + self.value.refresh_from_db() + self.assertTrue(self.link.submitted) + self.assertEqual(self.value.value_text, "rgdftb") + self.assertEqual(self.value.field_id, self.field.pk) + self.assertEqual(self.link.field_values.count(), 1) + + def test_submit_and_edit_in_one_post_is_rejected_atomically(self): + self.value.value_text = "A" + self.value.save() + self.assert_rejected(self.payload(value="B"), submitted=False) + + def test_edit_after_submission_is_rejected(self): + self.freeze() + self.assert_rejected(self.payload(value="B"), submitted=True) + + def test_changing_field_after_submission_is_rejected(self): + other_field = create_program_field(self.program) + self.freeze() + data = self.payload() + data[f"{self.prefix}-0-field"] = other_field.pk + self.assert_rejected(data, submitted=True) + + def test_add_after_submission_is_rejected(self): + other_field = create_program_field(self.program) + self.freeze() + data = self.payload() + data.update( + { + f"{self.prefix}-TOTAL_FORMS": "2", + f"{self.prefix}-1-id": "", + f"{self.prefix}-1-program_project": self.link.pk, + f"{self.prefix}-1-field": other_field.pk, + f"{self.prefix}-1-value_text": "new", + } + ) + self.assert_rejected(data, submitted=True) + + def test_delete_after_submission_is_rejected(self): + self.freeze() + data = self.payload() + data[f"{self.prefix}-0-DELETE"] = "on" + self.assert_rejected(data, submitted=True) + + def test_submit_and_delete_in_one_post_is_rejected_atomically(self): + data = self.payload() + data[f"{self.prefix}-0-DELETE"] = "on" + self.assert_rejected(data, submitted=False) + + def test_submit_and_add_in_one_post_is_rejected_atomically(self): + other_field = create_program_field(self.program) + data = self.payload() + data.update( + { + f"{self.prefix}-TOTAL_FORMS": "2", + f"{self.prefix}-1-id": "", + f"{self.prefix}-1-program_project": self.link.pk, + f"{self.prefix}-1-field": other_field.pk, + f"{self.prefix}-1-value_text": "new", + } + ) + self.assert_rejected(data, submitted=False) + + def test_unsubmit_and_edit_cannot_bypass_persisted_freeze(self): + self.freeze() + self.assert_rejected(self.payload(submitted=False, value="B"), submitted=True) + + def test_unsubmit_and_delete_cannot_bypass_persisted_freeze(self): + self.freeze() + data = self.payload(submitted=False) + data[f"{self.prefix}-0-DELETE"] = "on" + self.assert_rejected(data, submitted=True) + + def test_unchanged_submitted_inline_allows_unrelated_parent_metadata_edit(self): + self.freeze() + data = self.payload() + date = timezone.localtime(timezone.now()).replace(microsecond=0) + data["datetime_submitted_0"] = date.strftime("%Y-%m-%d") + data["datetime_submitted_1"] = date.strftime("%H:%M:%S") + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.link.refresh_from_db() + self.value.refresh_from_db() + self.assertTrue(self.link.submitted) + self.assertEqual(self.link.datetime_submitted, date) + self.assertEqual(self.value.value_text, "rgdftb") + + def test_edit_before_submission_still_saves(self): + response = self.client.post(self.url, self.payload(submitted=False, value="B")) + self.assertEqual(response.status_code, 302) + self.link.refresh_from_db() + self.value.refresh_from_db() + self.assertFalse(self.link.submitted) + self.assertEqual(self.value.value_text, "B") + + def test_delete_before_submission_still_saves(self): + data = self.payload(submitted=False) + data[f"{self.prefix}-0-DELETE"] = "on" + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.link.refresh_from_db() + self.assertFalse(self.link.submitted) + self.assertFalse(self.link.field_values.exists()) + + +class GenericFieldValueAdminTests(FieldValueAdminFlow, TestCase): + pass + + +class CaseFieldValueAdminTests(FieldValueAdminFlow, TestCase): + is_case = True diff --git a/partner_programs/tests/test_field_value_mutation.py b/partner_programs/tests/test_field_value_mutation.py new file mode 100644 index 00000000..49da935a --- /dev/null +++ b/partner_programs/tests/test_field_value_mutation.py @@ -0,0 +1,156 @@ +"""DB identity and parent state determine freeze; unchanged validation is safe.""" + +from django.core.exceptions import ValidationError +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue, PartnerProgramProject +from partner_programs.services.field_values import FIELD_VALUES_FROZEN_MESSAGE +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, +) +from partner_programs.tests.test_case_fields import create_case_field + + +class ProgramFieldValueMutationTests(TestCase): + def setUp(self): + self.program = create_partner_program(is_competitive=True) + self.link = create_program_project(self.program) + self.generic = create_program_field(self.program) + self.case = create_case_field(self.program) + self.values = [ + PartnerProgramFieldValue.objects.create( + program_project=self.link, field=field, value_text="A" + ) + for field in (self.generic, self.case) + ] + + def freeze_db_only(self): + PartnerProgramProject.objects.filter(pk=self.link.pk).update(submitted=True) + self.assertFalse(self.link.submitted) + + def assert_frozen(self, action): + with self.assertRaisesMessage(ValidationError, FIELD_VALUES_FROZEN_MESSAGE): + action() + + def test_unchanged_values_validate_and_save_with_pending_parent_submission(self): + self.link.submitted = True + for value in self.values: + with self.subTest(field=value.field.name): + value.full_clean() + value.save() + value.refresh_from_db() + self.assertEqual(value.value_text, "A") + + def test_unchanged_values_validate_and_save_on_persisted_submitted_parent(self): + self.freeze_db_only() + for value in self.values: + with self.subTest(field=value.field.name): + value.full_clean() + value.save() + value.refresh_from_db() + self.assertEqual(value.value_text, "A") + + def test_api_still_rejects_identical_put_after_submission(self): + self.freeze_db_only() + client = APIClient() + client.force_authenticate(self.link.project.leader) + data = [ + {"field_id": value.field_id, "value_text": value.value_text} + for value in self.values + ] + for url in ( + f"/programs/partner-program-projects/{self.link.pk}/fields/", + f"/projects/{self.link.project_id}/program-fields/", + ): + with self.subTest(url=url): + response = client.put(url, data, format="json") + self.assertEqual(response.status_code, 400) + self.assertIn(FIELD_VALUES_FROZEN_MESSAGE, str(response.data)) + self.assertCountEqual( + list(self.link.field_values.values_list("value_text", flat=True)), ["A", "A"] + ) + + def test_changed_value_uses_db_freeze_despite_stale_parent(self): + self.freeze_db_only() + for value in self.values: + value.value_text = "B" + with self.subTest(field=value.field.name): + self.assert_frozen(value.full_clean) + self.assert_frozen(value.save) + value.refresh_from_db() + self.assertEqual(value.value_text, "A") + + def test_pending_submission_does_not_get_lost_during_case_save_lock(self): + self.link.submitted = True + for value in self.values: + value.value_text = "B" + with self.subTest(field=value.field.name): + self.assert_frozen(value.full_clean) + self.assert_frozen(value.save) + + def test_changing_field_is_a_mutation_even_with_same_text(self): + self.freeze_db_only() + other = create_program_field(self.program) + for value in self.values: + with self.subTest(field=value.field.name): + value.field = other + self.assert_frozen(value.full_clean) + + def test_moving_value_out_of_frozen_source_link_is_a_mutation(self): + self.freeze_db_only() + other = create_program_project(self.program) + for value in self.values: + value.program_project = other + with self.subTest(field=value.field.name): + self.assert_frozen(value.full_clean) + self.assert_frozen(value.save) + + def test_moving_value_into_frozen_target_link_is_a_mutation(self): + other = create_program_project(self.program, submitted=True) + for value in self.values: + value.program_project = other + with self.subTest(field=value.field.name): + self.assert_frozen(value.full_clean) + + def test_new_values_on_frozen_link_are_rejected(self): + self.freeze_db_only() + PartnerProgramFieldValue.objects.filter(program_project=self.link).delete() + for field in (self.generic, self.case): + value = PartnerProgramFieldValue( + program_project=self.link, field=field, value_text="A" + ) + with self.subTest(field=field.name): + self.assert_frozen(value.full_clean) + self.assert_frozen(value.save) + self.assertFalse(self.link.field_values.exists()) + + def test_same_pk_does_not_make_changed_text_unchanged(self): + value = self.values[0] + PartnerProgramFieldValue.objects.filter(pk=value.pk).update(value_text="B") + self.freeze_db_only() + self.assert_frozen(value.full_clean) + + def test_noncompetitive_submission_does_not_freeze_generic_or_case_values(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + self.link.submitted = True + self.link.save(update_fields=["submitted"]) + for value in self.values: + value.value_text = "B" + value.full_clean() + value.save() + value.refresh_from_db() + self.assertEqual(value.value_text, "B") + + def test_unchanged_case_still_runs_case_option_validation(self): + value = self.values[1] + PartnerProgramFieldValue.objects.filter(pk=value.pk).update(value_text="stale") + value.refresh_from_db() + self.freeze_db_only() + with self.assertRaisesMessage( + ValidationError, "Выбранный кейс больше недоступен" + ): + value.full_clean() diff --git a/partner_programs/tests/test_program_link_fields_api.py b/partner_programs/tests/test_program_link_fields_api.py new file mode 100644 index 00000000..da96d97a --- /dev/null +++ b/partner_programs/tests/test_program_link_fields_api.py @@ -0,0 +1,318 @@ +"""Link-scoped field contract, legacy compatibility, isolation and bounded reads.""" + +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from rest_framework.test import APIClient + +from invites.models import Invite +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +class ProgramLinkFieldsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user() + self.client.force_authenticate(self.leader) + self.program = create_partner_program(is_competitive=True) + self.other_program = create_partner_program(is_competitive=True) + self.project = create_project(leader=self.leader, draft=True, is_public=False) + self.link = create_program_project(self.program, project=self.project) + self.other_link = create_program_project(self.other_program, project=self.project) + self.field = create_case_field(self.program) + self.other_field = create_case_field(self.other_program, options=["Other"]) + self.url = f"/programs/partner-program-projects/{self.link.pk}/fields/" + self.legacy_url = f"/projects/{self.project.pk}/program-fields/" + + def put(self, items, url=None): + return self.client.put(url or self.url, items, format="json") + + def test_get_context_order_null_value_and_other_program_isolation(self): + extra = create_program_field(self.program) + PartnerProgramFieldValue.objects.create( + program_project=self.other_link, field=self.other_field, value_text="Other" + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual( + {key: value for key, value in response.data.items() if key != "fields"}, + { + "program_link_id": self.link.pk, + "program_id": self.program.pk, + "project_id": self.project.pk, + "submitted": False, + }, + ) + fields = response.data["fields"] + self.assertEqual([item["id"] for item in fields], [self.field.pk, extra.pk]) + self.assertIsNone(fields[0]["value"]) + self.assertEqual(fields[0]["options"], ["A", "B", "C"]) + self.assertEqual( + set(fields[0]), + { + "id", + "name", + "label", + "field_type", + "is_required", + "show_filter", + "help_text", + "options", + "value", + }, + ) + + def test_partial_update_changes_only_requested_link(self): + extra = create_program_field(self.program) + self.put([{"field_id": extra.pk, "value_text": "preserve"}]) + other_url = f"/programs/partner-program-projects/{self.other_link.pk}/fields/" + self.assertEqual( + self.put( + [{"field_id": self.other_field.pk, "value_text": "Other"}], other_url + ).status_code, + 200, + ) + for value in ("A", "B"): + self.assertEqual( + self.put([{"field_id": self.field.pk, "value_text": value}]).status_code, + 200, + ) + 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") + self.assertEqual(self.client.get(self.url).data["fields"][0]["value"], "B") + + def test_cannot_use_other_program_field_even_when_same_project(self): + response = self.put([{"field_id": self.other_field.pk, "value_text": "invalid"}]) + self.assertEqual(response.status_code, 400) + self.assertNotIn("Other", str(response.data)) + self.assertFalse(PartnerProgramFieldValue.objects.exists()) + + def test_duplicate_ids_are_rejected_without_writes(self): + response = self.put( + [{"field_id": self.field.pk, "value_text": value} for value in ("A", "B")] + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(self.link.field_values.exists()) + + def test_invalid_second_item_leaves_first_value_unchanged(self): + extra = create_program_field(self.program) + self.put([{"field_id": extra.pk, "value_text": "before"}]) + response = self.put( + [ + {"field_id": extra.pk, "value_text": "after"}, + {"field_id": self.field.pk, "value_text": "missing"}, + ] + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.link.field_values.get(field=extra).value_text, "before") + self.assertFalse(self.link.field_values.filter(field=self.field).exists()) + + def test_case_cannot_be_explicitly_empty(self): + for value in (None, "", " ", "missing"): + with self.subTest(value=value): + self.assertEqual( + self.put( + [{"field_id": self.field.pk, "value_text": value}] + ).status_code, + 400, + ) + self.assertFalse(self.link.field_values.exists()) + + def test_model_validation_error_rolls_back_an_earlier_write(self): + extra = create_program_field(self.program) + original_save = PartnerProgramFieldValue.save + + def save(value, *args, **kwargs): + if value.field_id == self.field.pk: + raise ValidationError("Конфигурация кейса изменилась.") + return original_save(value, *args, **kwargs) + + with patch.object(PartnerProgramFieldValue, "save", save): + response = self.put( + [ + {"field_id": extra.pk, "value_text": "must roll back"}, + {"field_id": self.field.pk, "value_text": "A"}, + ] + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(self.link.field_values.exists()) + + def test_submitted_competitive_link_blocks_all_field_updates(self): + extra = create_program_field(self.program) + self.link.submitted = True + self.link.save() + for field, value in ((self.field, "B"), (extra, "text")): + self.assertEqual( + self.put([{"field_id": field.pk, "value_text": value}]).status_code, 400 + ) + self.assertFalse(self.link.field_values.exists()) + + def test_get_allows_existing_project_involvement_and_current_program_roles(self): + manager = create_user() + self.program.managers.add(manager) + expert = create_rate_expert(program=self.program) + teammate = create_user() + create_program_member(self.program, user=teammate) + create_program_member(self.other_program, user=teammate) + Collaborator.objects.create(user=teammate, project=self.project) + invited = create_user() + Invite.objects.create(user=invited, project=self.project) + for user in ( + self.leader, + manager, + expert, + teammate, + invited, + create_user(is_staff=True), + create_user(is_superuser=True), + ): + with self.subTest(user=user.pk): + self.client.force_authenticate(user) + self.assertEqual(self.client.get(self.url).status_code, 200) + + def test_program_roles_are_scoped_to_requested_link_after_production_732(self): + manager = create_user() + self.program.managers.add(manager) + expert = create_rate_expert(program=self.program) + other_url = f"/programs/partner-program-projects/{self.other_link.pk}/fields/" + for user in (manager, expert): + self.client.force_authenticate(user) + with self.subTest(user=user.pk): + allowed = self.client.get(self.url) + self.assertEqual(allowed.status_code, 200) + self.assertEqual(allowed.data["program_link_id"], self.link.pk) + self.assertEqual(self.client.get(other_url).status_code, 403) + + def test_independent_project_roles_can_read_both_links(self): + teammate = create_user() + create_program_member(self.program, user=teammate) + create_program_member(self.other_program, user=teammate) + Collaborator.objects.create(user=teammate, project=self.project) + invited = create_user() + Invite.objects.create(user=invited, project=self.project) + for user in ( + self.leader, + teammate, + invited, + create_user(is_staff=True), + create_user(is_superuser=True), + ): + self.program.managers.add(user) + self.client.force_authenticate(user) + for link in (self.link, self.other_link): + with self.subTest(user=user.pk, link=link.pk): + response = self.client.get( + f"/programs/partner-program-projects/{link.pk}/fields/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["program_link_id"], link.pk) + + def test_get_does_not_leak_private_fields_to_outsider_or_other_program_roles(self): + manager = create_user() + self.other_program.managers.add(manager) + for user in ( + create_user(), + manager, + create_rate_expert(program=self.other_program), + ): + self.client.force_authenticate(user) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_public_project_alone_does_not_expose_program_fields(self): + self.project.draft = False + self.project.is_public = True + self.project.save() + self.client.force_authenticate(create_user()) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_put_is_leader_only_without_manager_expert_or_staff_override(self): + manager = create_user() + self.program.managers.add(manager) + for user in ( + create_user(), + manager, + create_rate_expert(program=self.program), + create_user(is_staff=True), + create_user(is_superuser=True), + ): + self.client.force_authenticate(user) + self.assertEqual( + self.put([{"field_id": self.field.pk, "value_text": "A"}]).status_code, + 403, + ) + self.assertFalse(self.link.field_values.exists()) + + def test_authentication_missing_link_and_unsupported_method(self): + self.client.force_authenticate(None) + self.assertEqual(self.client.get(self.url).status_code, 401) + self.assertEqual(self.put([]).status_code, 401) + self.client.force_authenticate(self.leader) + self.assertEqual( + self.client.get( + "/programs/partner-program-projects/999999/fields/" + ).status_code, + 404, + ) + self.assertEqual(self.client.post(self.url, [], format="json").status_code, 405) + + def test_legacy_multiple_links_returns_409_without_selecting_first(self): + response = self.put( + [{"field_id": self.field.pk, "value_text": "A"}], self.legacy_url + ) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["detail"].code, "ambiguous_program_link") + self.assertFalse(PartnerProgramFieldValue.objects.exists()) + + def test_legacy_single_link_still_works(self): + self.other_link.delete() + self.assertEqual( + self.put( + [{"field_id": self.field.pk, "value_text": "A"}], self.legacy_url + ).status_code, + 200, + ) + self.assertEqual(self.link.field_values.get().value_text, "A") + + def test_legacy_no_links_returns_controlled_error(self): + self.other_link.delete() + self.link.delete() + self.assertEqual(self.put([], self.legacy_url).status_code, 400) + + def get_query_counts(self, users, expected_fields): + counts = [] + for user in users: + self.client.force_authenticate(user) + with CaptureQueriesContext(connection) as queries: + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["fields"]), expected_fields) + counts.append(len(queries)) + return counts + + def test_get_has_constant_queries_for_one_or_twenty_fields(self): + manager = create_user() + self.program.managers.add(manager) + expert = create_rate_expert(program=self.program) + users = (self.leader, manager, expert) + single = self.get_query_counts(users, 1) + for _ in range(19): + field = create_program_field(self.program) + PartnerProgramFieldValue.objects.create( + program_project=self.link, field=field, value_text="text" + ) + self.assertEqual(self.get_query_counts(users, 20), single) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 2fba577d..a73a8f28 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -28,6 +28,7 @@ PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, + PartnerProgramProjectFieldsView, PartnerProgramRegister, PartnerProgramSetLiked, PartnerProgramSetViewed, @@ -86,6 +87,11 @@ PartnerProgramProjectSubmitView.as_view(), name="partner-program-project-submit", ), + path( + "partner-program-projects//fields/", + PartnerProgramProjectFieldsView.as_view(), + name="partner-program-project-fields", + ), path("/schema/", PartnerProgramDataSchema.as_view()), path("/register/", PartnerProgramRegister.as_view()), path("/register_new/", PartnerProgramCreateUserAndRegister.as_view()), diff --git a/partner_programs/views.py b/partner_programs/views.py index 8c2029af..015f00b5 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,13 +1,9 @@ from django.contrib.auth import get_user_model -from django.db import transaction from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.utils import timezone -from django.utils.timezone import now -from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, permissions, status -from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response @@ -19,7 +15,6 @@ from core.utils import build_xlsx_download_response from partner_programs.models import ( PartnerProgram, - PartnerProgramFieldValue, PartnerProgramProject, PartnerProgramUserProfile, ) @@ -54,6 +49,12 @@ require_can_apply_project_to_program, ) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from partner_programs.services.project_fields import ( + program_link_fields, + resolve_legacy_program_link, + submit_program_project, + update_program_link_fields, +) from projects.models import Project from projects.serializers import ProjectListSerializer @@ -309,113 +310,42 @@ class PartnerProgramDataSchema(generics.RetrieveAPIView): class PartnerProgramFieldValueBulkUpdateView(APIView): + """Backward-compatible write facade for projects with exactly one program link.""" + permission_classes = [IsAuthenticated] serializer_class = PartnerProgramFieldValueUpdateSerializer - def get_project(self, project_id): - try: - return Project.objects.get(id=project_id) - except Project.DoesNotExist: - raise NotFound("Проект не найден") - @swagger_auto_schema(request_body=PartnerProgramFieldValueUpdateSerializer(many=True)) def put(self, request, project_id, *args, **kwargs): - project = self.get_project(project_id) - - if project.leader != request.user: - raise PermissionDenied("Вы не являетесь лидером этого проекта") + link_id = resolve_legacy_program_link(project_id, request.user) + update_program_link_fields(link_id, request.user, request.data) + return Response({"detail": "Значения успешно обновлены"}) - try: - program_project = PartnerProgramProject.objects.select_related( - "partner_program" - ).get(project=project) - except PartnerProgramProject.DoesNotExist: - raise ValidationError("Проект не привязан ни к одной программе") - - partner_program = program_project.partner_program - if partner_program.is_competitive and program_project.submitted: - raise ValidationError( - "Нельзя изменять значения полей программы после сдачи проекта на проверку." - ) - - serializer = self.serializer_class(data=request.data, many=True) - serializer.is_valid(raise_exception=True) +class PartnerProgramProjectFieldsView(APIView): + """Canonical read/partial PUT contract scoped to one program-project link.""" - with transaction.atomic(): - for item in serializer.validated_data: - field = item["field"] - - if field.partner_program_id != partner_program.id: - raise ValidationError( - f"Поле с id={field.id} не относится к программе этого проекта" - ) - - value_text = item.get("value_text") - - obj, created = PartnerProgramFieldValue.objects.update_or_create( - program_project=program_project, - field=field, - defaults={"value_text": value_text}, - ) + permission_classes = [IsAuthenticated] - if created: - try: - obj.full_clean() - except ValidationError as e: - raise ValidationError(e.message_dict) + def get(self, request, pk, *args, **kwargs): + return Response(program_link_fields(pk, request.user)) - return Response( - {"detail": "Значения успешно обновлены"}, - status=status.HTTP_200_OK, - ) + @swagger_auto_schema(request_body=PartnerProgramFieldValueUpdateSerializer(many=True)) + def put(self, request, pk, *args, **kwargs): + update_program_link_fields(pk, request.user, request.data) + return Response({"detail": "Значения успешно обновлены"}) class PartnerProgramProjectSubmitView(GenericAPIView): + """Submit the URL link atomically, keeping the existing leader-only contract.""" + permission_classes = [IsAuthenticated, IsProjectLeader] serializer_class = EmptySerializer queryset = PartnerProgramProject.objects.all() - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( - name="id", - in_=openapi.IN_PATH, - description="Уникальный идентификатор связи проекта и программы", - type=openapi.TYPE_INTEGER, - required=True, - ), - ] - ) def post(self, request, pk, *args, **kwargs): - program_project = self.get_object() - - if not program_project.partner_program.is_competitive: - return Response( - {"detail": "Программа не является конкурсной."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if program_project.submitted: - return Response( - {"detail": "Проект уже был сдан на проверку."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - if not program_project.partner_program.is_project_submission_open(): - return Response( - {"detail": "Срок подачи проектов в программу завершён."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - program_project.submitted = True - program_project.datetime_submitted = now() - program_project.save() - - return Response( - {"detail": "Проект успешно сдан на проверку."}, - status=status.HTTP_200_OK, - ) + submit_program_project(pk, request.user) + return Response({"detail": "Проект успешно сдан на проверку."}) class ProgramFiltersAPIView(APIView): diff --git a/projects/access.py b/projects/access.py index 1728190c..a00c1c41 100644 --- a/projects/access.py +++ b/projects/access.py @@ -35,3 +35,11 @@ def has_project_read_involvement(user, project: Project) -> bool: has_project_level_read_access(user, project) or program_role_project_links(user, project).exists() ) + + +def has_program_link_read_access(user, link: PartnerProgramProject) -> bool: + """Read this link's fields, never another program via shared Project access.""" + return ( + has_project_level_read_access(user, link.project) + or program_role_project_links(user, link.project).filter(pk=link.pk).exists() + )