diff --git a/docs/program-management-project-form.md b/docs/program-management-project-form.md new file mode 100644 index 00000000..1abe4d83 --- /dev/null +++ b/docs/program-management-project-form.md @@ -0,0 +1,28 @@ +# Organizer: cases and common project fields + +`GET /programs/{pk}/management/project-form/` and `PUT` at the same URL require an authenticated manager of that exact program or staff/superuser. They edit definitions; answer writes remain leader-only and submitted competitive answers remain frozen. + +GET returns `updated_at`, definition `revision`, `has_projects`, `cases: [{name, projects_count}]`, `fields` (existing field contract plus `answers_count` and `editable_type`) and `supported_types`. + +PUT replaces the definition configuration atomically: + +```json +{ + "expected_updated_at": "2026-10-17T10:00:00Z", + "expected_revision": "sha256 returned by GET", + "cases": ["Кейс А", "Кейс Б"], + "fields": [{"id": null, "label": "Прототип", "field_type": "text", "is_required": true, "help_text": "Откройте доступ", "options": []}] +} +``` + +Existing IDs must belong to the URL program. New field names are generated server-side; existing names and filter settings are preserved. Cases use the existing reserved `case` select field; descriptions and tasks stay in materials. All cases share the same fields. Order follows existing primary keys, with new fields appended; this endpoint does not offer reordering. + +New types: text, textarea, select, file. Existing checkbox/radio definitions remain readable and can have their labels/help edited while retaining their type/options/required settings. File answers use the existing uploaded-file URL contract. + +Used case names cannot be renamed/deleted. Fields with nonempty saved answers cannot be deleted or change type; used choices cannot be removed/renamed. Empty historical value rows do not lock a generic field. Labels, help and required-before-submission settings can change. New required fields are allowed after projects exist: only projects not yet submitted must fill them before submission. Already submitted projects retain status and answers. + +Draft apply and field PUT accept missing/empty additional fields and case. Explicit empty values remove the answer row; omission in partial PUT leaves it unchanged. Invalid nonempty values still fail validation. Required values are checked at submission; a generic missing-field error retains `detail` and additionally returns `code: required_program_fields` so clients can reload current definitions while preserving edits. Case/deadline/team/leader guards remain in force. + +400 is validation failure without partial writes; 401/403 enforce authentication/scope; 409 rejects an old program timestamp or definition revision. The revision detects direct admin definition changes too. Program/link/definition locking serializes organizer saves against project creation, answer PUT and submission. + +No schema or data migration. Deploy backend before the frontend. Old clients and other form consumers retain their existing response shapes; draft validation is intentionally relaxed. diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 888c2cce..d55b2566 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -44,6 +44,18 @@ def get(self, request, pk): return Response(management_data(managed_program(pk, request.user))) +class ManagementProjectFormView(ManagementBase): + def get(self, request, pk): + from partner_programs.services.management_form import form_data + + return Response(form_data(managed_program(pk, request.user))) + + def put(self, request, pk): + from partner_programs.services.management_form import save_form + + return Response(save_form(pk, request.user, request.data)) + + class ManagementParticipantsView(ManagementBase): def get(self, request, pk): program = managed_program(pk, request.user) diff --git a/partner_programs/serializers/fields.py b/partner_programs/serializers/fields.py index 443c246b..35d67041 100644 --- a/partner_programs/serializers/fields.py +++ b/partner_programs/serializers/fields.py @@ -32,6 +32,12 @@ def validate(self, attrs): field = attrs.get("field") value_text = attrs.get("value_text") + if self.context.get("allow_incomplete") and ( + value_text is None or not str(value_text).strip() + ): + attrs["value_text"] = "" + return attrs + validator = self._get_validator(field) validator(field, value_text, attrs) diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index a5842646..924f17af 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -279,8 +279,7 @@ class Meta: ] def get_value(self, obj): - if obj.field.field_type == "file": - return obj.value_file.link if obj.value_file else None + # File fields use the same URL-in-value_text contract as canonical link GET/PUT. return obj.value_text diff --git a/partner_programs/services/management_form.py b/partner_programs/services/management_form.py new file mode 100644 index 00000000..8098504c --- /dev/null +++ b/partner_programs/services/management_form.py @@ -0,0 +1,240 @@ +"""Atomic organizer editing of program case names and shared project fields.""" + +import hashlib +import json +from uuid import uuid4 + +from django.core.exceptions import ValidationError as ModelValidationError +from django.db import transaction +from django.db.models import Count, Q +from rest_framework import serializers +from rest_framework.exceptions import APIException, ValidationError + +from partner_programs.models import PartnerProgramField, PartnerProgramProject +from partner_programs.serializers.management import StrictFieldsMixin +from partner_programs.services.management import managed_program + +SUPPORTED_TYPES = ("text", "textarea", "select", "file") + + +class StaleProjectForm(APIException): + status_code = 409 + default_code = "stale_project_form" + default_detail = "Кейсы или поля изменены другим организатором. Обновите настройки." + + def __init__(self): + super().__init__({"code": self.default_code, "detail": self.default_detail}) + + +class FormFieldWrite(StrictFieldsMixin, serializers.Serializer): + id = serializers.IntegerField(required=False, allow_null=True, min_value=1) + label = serializers.CharField(max_length=256) + field_type = serializers.ChoiceField( + choices=[p[0] for p in PartnerProgramField.FIELD_TYPES] + ) + is_required = serializers.BooleanField() + help_text = serializers.CharField( + required=False, allow_blank=True, default="", max_length=4000 + ) + options = serializers.ListField( + child=serializers.CharField(max_length=1000), max_length=200 + ) + + +class ProjectFormWrite(StrictFieldsMixin, serializers.Serializer): + expected_updated_at = serializers.DateTimeField() + expected_revision = serializers.CharField(max_length=64) + cases = serializers.ListField( + child=serializers.CharField(max_length=1000), max_length=200 + ) + fields = FormFieldWrite(many=True) + + def validate_fields(self, value): + if len(value) > 100: + raise serializers.ValidationError("Допустимо не больше 100 полей.") + ids = [v["id"] for v in value if v.get("id") is not None] + if len(ids) != len(set(ids)): + raise serializers.ValidationError("Поле указано несколько раз.") + return value + + +def _options(values): + if any(not v.strip() or "|" in v for v in values): + raise ValidationError("Варианты должны быть непустыми и не содержать символ |.") + if len({v.strip().casefold() for v in values}) != len(values): + raise ValidationError("Варианты не должны повторяться.") + return [v.strip() for v in values] + + +def _definitions(program): + return list(program.fields.order_by("pk")) + + +def form_data(program): + fields = _definitions(program) + definitions = [ + { + "id": f.pk, + "name": f.name, + "label": f.label, + "field_type": f.field_type, + "is_required": f.is_required, + "help_text": f.help_text or "", + "options": f.get_options_list(), + "show_filter": f.show_filter, + } + for f in fields + ] + revision = hashlib.sha256( + json.dumps(definitions, ensure_ascii=False, sort_keys=True).encode() + ).hexdigest() + counts = dict( + program.fields.annotate( + total=Count("values", filter=Q(values__value_text__regex=r"\S")) + ).values_list("pk", "total") + ) + case = next((f for f in fields if f.name == "case"), None) + case_counts = ( + dict( + case.values.values("value_text") + .annotate(total=Count("program_project", distinct=True)) + .values_list("value_text", "total") + ) + if case + else {} + ) + return { + "updated_at": program.datetime_updated, + "revision": revision, + "has_projects": PartnerProgramProject.objects.filter( + partner_program=program + ).exists(), + "cases": [ + {"name": option, "projects_count": case_counts.get(option, 0)} + for option in (case.get_options_list() if case else []) + ], + "fields": [ + { + **data, + "answers_count": counts.get(data["id"], 0), + "editable_type": data["field_type"] in SUPPORTED_TYPES, + } + for data in definitions + if data["name"] != "case" + ], + "supported_types": SUPPORTED_TYPES, + } + + +@transaction.atomic +def save_form(program_id, user, payload): + program = managed_program(program_id, user, lock=True) + # Same link -> definition order as field PUT/submit; application creation + # already locks the program, so the first project cannot race configuration. + list( + PartnerProgramProject.objects.filter(partner_program=program) + .order_by("pk") + .select_for_update() + ) + existing = list(program.fields.order_by("pk").select_for_update()) + serializer = ProjectFormWrite(data=payload) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + snapshot = form_data(program) + if ( + data["expected_updated_at"] != program.datetime_updated + or data["expected_revision"] != snapshot["revision"] + ): + raise StaleProjectForm() + cases = _options(data["cases"]) + generic = {f.pk: f for f in existing if f.name != "case"} + case = next((f for f in existing if f.name == "case"), None) + ids = {item["id"] for item in data["fields"] if item.get("id") is not None} + if ids - generic.keys(): + raise ValidationError({"fields": "Поле не относится к этой программе."}) + for pk, field in generic.items(): + if pk not in ids: + if field.values.filter(value_text__regex=r"\S").exists(): + raise ValidationError( + {"fields": "Нельзя удалить поле с сохранёнными ответами."} + ) + field.delete() + for item in data["fields"]: + field = generic.get(item.get("id")) + new = field is None + if new and item["field_type"] not in SUPPORTED_TYPES: + raise ValidationError({"fields": "Этот тип недоступен для новых полей."}) + options = ( + _options(item["options"]) + if item["field_type"] in ("select", "radio") + else item["options"] + ) + if item["field_type"] in ("select", "radio") and not options: + raise ValidationError({"fields": "Укажите варианты ответа."}) + if item["field_type"] not in ("select", "radio", "checkbox") and options: + raise ValidationError({"fields": "Варианты доступны только полям выбора."}) + if field and field.values.filter(value_text__regex=r"\S").exists(): + if field.field_type != item["field_type"]: + raise ValidationError({"fields": "Нельзя изменить тип поля с ответами."}) + if field.field_type in ("select", "radio"): + used = set( + field.values.exclude(value_text="").values_list( + "value_text", flat=True + ) + ) + if used - set(options): + raise ValidationError( + { + "fields": "Нельзя удалить или переименовать использованный вариант ответа." + } + ) + if field and field.field_type not in SUPPORTED_TYPES: + if ( + item["field_type"] != field.field_type + or options != field.get_options_list() + or item["is_required"] != field.is_required + ): + raise ValidationError( + { + "fields": "Настройки существующего специального поля нужно сохранить." + } + ) + if new: + field = PartnerProgramField( + partner_program=program, name="field_" + uuid4().hex + ) + field.label = item["label"] + field.field_type = item["field_type"] + field.is_required = item["is_required"] + field.help_text = item["help_text"] + field.options = "|".join(options) + try: + field.full_clean() + field.save() + except ModelValidationError as error: + raise ValidationError({"fields": error.messages}) + if case: + if not cases: + if case.values.exists(): + raise ValidationError( + {"cases": "Нельзя удалить кейсы, которые уже выбрали команды."} + ) + case.delete() + else: + case.options = "|".join(cases) + try: + case.save() + except ModelValidationError as error: + raise ValidationError({"cases": error.messages}) + elif cases: + PartnerProgramField.objects.create( + partner_program=program, + name="case", + label="Кейс", + field_type="select", + is_required=True, + show_filter=True, + options="|".join(cases), + ) + program.save(update_fields=["datetime_updated"]) + return form_data(program) diff --git a/partner_programs/services/project_apply.py b/partner_programs/services/project_apply.py index da341f54..1768cb49 100644 --- a/partner_programs/services/project_apply.py +++ b/partner_programs/services/project_apply.py @@ -5,7 +5,6 @@ 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, @@ -67,31 +66,6 @@ def _validate_unique_program_fields(values_data: list[dict]) -> None: ) -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) - .exclude(name=PROGRAM_CASE_FIELD_NAME) - .values("id", "label") - ) - provided_field_ids = {item["field"].id for item in values_data} - missing_required = [ - field["label"] - for field in required_fields - if field["id"] not in provided_field_ids - ] - if missing_required: - raise ValidationError( - { - "program_field_values": f"Не заполнены обязательные поля: {missing_required}" - } - ) - - def _validate_program_field_ownership( *, program: PartnerProgram, @@ -133,7 +107,9 @@ def apply_project_to_program( program, project_id=None, user_ids={user.pk}, lock=True ) - serializer = serializer_class(data=data, context={"program": program}) + serializer = serializer_class( + data=data, context={"program": program, "allow_incomplete": True} + ) serializer.is_valid(raise_exception=True) validated_data = serializer.validated_data @@ -141,7 +117,6 @@ def apply_project_to_program( values_data = validated_data.get("program_field_values") or [] _validate_unique_program_fields(values_data) - _validate_required_program_fields(program=program, values_data=values_data) _validate_program_field_ownership(program=program, values_data=values_data) with transaction.atomic(): @@ -150,7 +125,7 @@ def apply_project_to_program( 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: + if item["field"].pk == case_field.pk and item.get("value_text"): try: validate_case_value(case_field, item.get("value_text")) except DjangoValidationError as error: @@ -181,6 +156,7 @@ def apply_project_to_program( value_text=item.get("value_text") or "", ) for item in values_data + if item.get("value_text") ] if value_objs: PartnerProgramFieldValue.objects.bulk_create(value_objs) diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py index 0e7c4f2b..7e401b88 100644 --- a/partner_programs/services/project_fields.py +++ b/partner_programs/services/project_fields.py @@ -91,7 +91,9 @@ def update_program_link_fields(link_id, user, data): # 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} + data=data, + many=True, + context={"program": link.partner_program, "allow_incomplete": True}, ) serializer.is_valid(raise_exception=True) field_ids = [item["field"].pk for item in serializer.validated_data] @@ -99,6 +101,9 @@ def update_program_link_fields(link_id, user, data): raise ValidationError({"detail": "В запросе не должны повторяться field_id."}) try: for item in serializer.validated_data: + if not item.get("value_text"): + link.field_values.filter(field=item["field"]).delete() + continue PartnerProgramFieldValue.objects.update_or_create( program_project=link, field=item["field"], @@ -131,7 +136,9 @@ def submit_program_project(link_id, user): try: team_policy.validate_required_program_fields(link) except DjangoValidationError as error: - raise ValidationError({"detail": error.messages[0]}) + raise ValidationError( + {"code": "required_program_fields", "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_lifecycle_api.py b/partner_programs/tests/test_case_lifecycle_api.py index b73e57de..de6a6c11 100644 --- a/partner_programs/tests/test_case_lifecycle_api.py +++ b/partner_programs/tests/test_case_lifecycle_api.py @@ -61,7 +61,7 @@ def test_apply_with_explicit_valid_case_preserves_choice(self): self.assertEqual(PartnerProgramFieldValue.objects.get().value_text, "B") def test_apply_explicit_invalid_case_does_not_create_project(self): - for value in (None, "", "unknown"): + for value in ("unknown",): self.assertEqual( self.apply( [{"field_id": self.field.pk, "value_text": value}] @@ -70,12 +70,57 @@ def test_apply_explicit_invalid_case_does_not_create_project(self): ) self.assertFalse(PartnerProgramProject.objects.exists()) - def test_other_required_fields_remain_required_on_apply(self): + def test_required_fields_and_explicit_empty_case_allow_draft_but_not_submit(self): field = create_program_field(self.program, name="track", is_required=True) - self.assertEqual(self.apply().status_code, 400) + response = self.apply( + [ + {"field_id": self.field.pk, "value_text": ""}, + {"field_id": field.pk, "value_text": ""}, + ] + ) + self.assertEqual(response.status_code, 201, response.data) + link = PartnerProgramProject.objects.get(pk=response.data["program_link_id"]) + fields_url = f"/programs/partner-program-projects/{link.pk}/fields/" + self.assertEqual(self.submit(link).status_code, 400) self.assertEqual( - self.apply([{"field_id": field.pk, "value_text": "answer"}]).status_code, 201 + self.client.put( + fields_url, + [ + {"field_id": self.field.pk, "value_text": "A"}, + {"field_id": field.pk, "value_text": ""}, + ], + format="json", + ).status_code, + 200, + ) + self.assertEqual(self.submit(link).status_code, 400) + self.assert_not_submitted(link) + self.assertEqual( + self.client.put( + fields_url, + [ + {"field_id": field.pk, "value_text": "Complete"}, + ], + format="json", + ).status_code, + 200, + ) + self.assertEqual(self.submit(link).status_code, 200) + + def test_draft_case_can_be_cleared(self): + link = self.link() + PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text="A" ) + response = self.client.put( + f"/programs/partner-program-projects/{link.pk}/fields/", + [ + {"field_id": self.field.pk, "value_text": ""}, + ], + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(link.field_values.exists()) def test_submit_missing_case_has_controlled_error_without_date_change(self): link = self.link() diff --git a/partner_programs/tests/test_management_project_form_api.py b/partner_programs/tests/test_management_project_form_api.py new file mode 100644 index 00000000..1c11c053 --- /dev/null +++ b/partner_programs/tests/test_management_project_form_api.py @@ -0,0 +1,283 @@ +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from django.db import connections +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_program_member, + create_user, +) + + +class ManagementProjectFormTests(TestCase): + def setUp(self): + self.program = create_partner_program(is_competitive=True) + self.manager = create_user() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = reverse( + "partner_programs:management-project-form", kwargs={"pk": self.program.pk} + ) + + def payload(self): + snapshot = self.client.get(self.url).data + keys = ("id", "label", "field_type", "is_required", "help_text", "options") + return { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "cases": [c["name"] for c in snapshot["cases"]], + "fields": [{k: f[k] for k in keys} for f in snapshot["fields"]], + } + + def put(self, data): + return self.client.put(self.url, data, format="json") + + def field(self, **overrides): + return { + "label": "Ссылка на прототип", + "field_type": "text", + "is_required": False, + "help_text": "Откройте доступ", + "options": [], + **overrides, + } + + def test_configuration_roundtrip_and_legacy_names(self): + old = create_program_field(self.program, name="legacy-prototype") + data = self.payload() + data["cases"] = ["Кейс А", "Кейс Б"] + data["fields"].append(self.field(field_type="select", options=["А", "Б"])) + response = self.put(data) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + response.data["cases"][0], {"name": "Кейс А", "projects_count": 0} + ) + self.assertNotEqual(response.data["revision"], data["expected_revision"]) + old.refresh_from_db() + self.assertEqual(old.name, "legacy-prototype") + self.assertEqual(len(response.data["fields"]), 2) + self.assertTrue(self.program.fields.get(name="case").is_required) + + def test_scope_and_auth_apply_to_reads_and_writes(self): + data = self.payload() + for user, status in [ + (create_user(), 403), + (None, 401), + (create_user(is_staff=True), 200), + ]: + self.client.force_authenticate(user) + self.assertEqual(self.client.get(self.url).status_code, status) + self.assertEqual(self.put(data).status_code, status) + + def test_stale_program_and_admin_definition_edits_rejected(self): + field = create_program_field(self.program) + data = self.payload() + field.label = "Changed in admin" + field.save() + self.assertEqual(self.put(data).status_code, 409) + fresh = self.payload() + self.program.name = "Changed basics" + self.program.save() + self.assertEqual(self.put(fresh).status_code, 409) + self.assertEqual(self.program.fields.get(pk=field.pk).label, "Changed in admin") + + def test_invalid_case_rolls_back_other_changes(self): + data = self.payload() + data["cases"] = ["Same", " same "] + data["fields"] = [self.field()] + self.assertEqual(self.put(data).status_code, 400) + self.assertFalse(self.program.fields.exists()) + + def test_foreign_and_duplicate_fields_rejected(self): + foreign = create_program_field(create_partner_program()) + for ids in ([foreign.pk], [foreign.pk, foreign.pk]): + data = self.payload() + data["fields"] = [self.field(id=pk) for pk in ids] + self.assertEqual(self.put(data).status_code, 400) + self.assertFalse(self.program.fields.exists()) + + def test_used_case_and_field_guards_preserve_answers(self): + case = create_program_field( + self.program, + name="case", + field_type="select", + is_required=True, + show_filter=True, + options=["A", "B"], + ) + field = create_program_field( + self.program, field_type="select", options=["One", "Two"] + ) + link = create_program_project(self.program) + for definition, value in [(case, "A"), (field, "One")]: + PartnerProgramFieldValue.objects.create( + field=definition, program_project=link, value_text=value + ) + snapshot = self.client.get(self.url).data + self.assertEqual(snapshot["cases"][0]["projects_count"], 1) + self.assertEqual(snapshot["fields"][0]["answers_count"], 1) + for change in ("case", "delete", "type", "choice"): + data = self.payload() + if change == "case": + data["cases"] = ["B"] + if change == "delete": + data["fields"] = [] + if change == "type": + data["fields"][0].update(field_type="text", options=[]) + if change == "choice": + data["fields"][0]["options"] = ["Two"] + self.assertEqual(self.put(data).status_code, 400, change) + self.assertEqual( + PartnerProgramFieldValue.objects.filter(program_project=link).count(), 2 + ) + + def test_unused_case_and_field_can_be_removed(self): + create_program_field( + self.program, + name="case", + field_type="select", + is_required=True, + show_filter=True, + options=["A"], + ) + create_program_field(self.program) + data = self.payload() + data.update(cases=[], fields=[]) + self.assertEqual(self.put(data).status_code, 200) + self.assertFalse(self.program.fields.exists()) + + def test_existing_checkbox_can_be_preserved_but_not_created(self): + old = create_program_field(self.program, field_type="checkbox", is_required=True) + data = self.payload() + data["fields"][0]["label"] = "Новое название" + self.assertEqual(self.put(data).status_code, 200) + old.refresh_from_db() + self.assertEqual(old.label, "Новое название") + data = self.payload() + data["fields"].append(self.field(field_type="checkbox")) + self.assertEqual(self.put(data).status_code, 400) + + def test_rejects_unknown_properties_and_invalid_choices(self): + for item in ( + self.field(unknown="value"), + self.field(field_type="select", options=[]), + self.field(field_type="select", options=["A|B"]), + self.field(options=["unexpected"]), + ): + data = self.payload() + data["fields"] = [item] + self.assertEqual(self.put(data).status_code, 400) + self.assertEqual( + PartnerProgramField.objects.filter(partner_program=self.program).count(), 0 + ) + + def test_new_required_fields_apply_only_to_unsubmitted_projects(self): + frozen = create_program_project(self.program, submitted=True) + draft = create_program_project(self.program) + create_program_member(self.program, user=frozen.project.leader) + create_program_member(self.program, user=draft.project.leader) + data = self.payload() + data["fields"] = [self.field(is_required=True)] + response = self.put(data) + self.assertEqual(response.status_code, 200, response.data) + frozen.refresh_from_db() + self.assertTrue(frozen.submitted) + self.assertFalse(frozen.field_values.exists()) + self.client.force_authenticate(frozen.project.leader) + self.assertEqual( + self.client.put( + f"/programs/partner-program-projects/{frozen.pk}/fields/", + [{"field_id": response.data["fields"][0]["id"], "value_text": "change"}], + format="json", + ).status_code, + 400, + ) + self.client.force_authenticate(draft.project.leader) + self.assertEqual( + self.client.post( + f"/programs/partner-program-projects/{draft.pk}/submit/" + ).status_code, + 400, + ) + draft.refresh_from_db() + self.assertFalse(draft.submitted) + + def test_empty_draft_value_does_not_lock_unused_field(self): + field = create_program_field(self.program) + link = create_program_project(self.program) + # Historical apply used bulk_create and could leave an empty value row. + PartnerProgramFieldValue.objects.bulk_create( + [PartnerProgramFieldValue(field=field, program_project=link, value_text="")] + ) + self.assertEqual(self.client.get(self.url).data["fields"][0]["answers_count"], 0) + data = self.payload() + data["fields"] = [] + self.assertEqual(self.put(data).status_code, 200) + self.assertFalse(self.program.fields.exists()) + + def test_late_used_case_error_rolls_back_new_field(self): + case = create_program_field( + self.program, + name="case", + field_type="select", + is_required=True, + show_filter=True, + options=["A", "B"], + ) + link = create_program_project(self.program) + PartnerProgramFieldValue.objects.create( + field=case, program_project=link, value_text="A" + ) + data = self.payload() + data["cases"] = ["B"] + data["fields"] = [self.field()] + self.assertEqual(self.put(data).status_code, 400) + self.assertEqual(self.program.fields.count(), 1) + self.assertEqual(link.field_values.get().value_text, "A") + + +@skipUnlessDBFeature("has_select_for_update") +class ManagementProjectFormLockTests(TransactionTestCase): + def test_concurrent_manager_saves_have_one_winner(self): + from partner_programs.services.management_form import ( + form_data, + save_form, + StaleProjectForm, + ) + + program = create_partner_program() + manager = create_user() + program.managers.add(manager) + snapshot = form_data(program) + barrier = Barrier(2) + + def save(name): + try: + barrier.wait(timeout=10) + save_form( + program.pk, + manager, + { + "expected_updated_at": snapshot["updated_at"], + "expected_revision": snapshot["revision"], + "cases": [name], + "fields": [], + }, + ) + return 200 + except StaleProjectForm: + return 409 + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(save, ["A", "B"])) + self.assertEqual(sorted(results), [200, 409]) + self.assertEqual(program.fields.count(), 1) diff --git a/partner_programs/tests/test_program_link_fields_api.py b/partner_programs/tests/test_program_link_fields_api.py index 82793df1..f5afe26a 100644 --- a/partner_programs/tests/test_program_link_fields_api.py +++ b/partner_programs/tests/test_program_link_fields_api.py @@ -241,15 +241,19 @@ def test_invalid_second_item_leaves_first_value_unchanged(self): 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"): + def test_case_can_be_cleared_in_draft_but_unknown_choice_is_invalid(self): + for value in (None, "", " "): with self.subTest(value=value): self.assertEqual( self.put( [{"field_id": self.field.pk, "value_text": value}] ).status_code, - 400, + 200, ) + self.assertEqual( + self.put([{"field_id": self.field.pk, "value_text": "missing"}]).status_code, + 400, + ) self.assertFalse(self.link.field_values.exists()) def test_model_validation_error_rolls_back_an_earlier_write(self): diff --git a/partner_programs/tests/test_project_apply.py b/partner_programs/tests/test_project_apply.py index 6dc3563c..ecb307a5 100644 --- a/partner_programs/tests/test_project_apply.py +++ b/partner_programs/tests/test_project_apply.py @@ -153,7 +153,7 @@ def test_apply_project_rejects_duplicate_field_values(self): self.assertEqual(response.status_code, 400) self.assertFalse(PartnerProgramProject.objects.exists()) - def test_apply_project_rejects_missing_required_program_fields(self): + def test_apply_project_allows_missing_required_program_fields(self): create_program_member(self.program, user=self.user) create_program_field( self.program, @@ -169,8 +169,8 @@ def test_apply_project_rejects_missing_required_program_fields(self): format="json", ) - self.assertEqual(response.status_code, 400) - self.assertFalse(PartnerProgramProject.objects.exists()) + self.assertEqual(response.status_code, 201) + self.assertTrue(PartnerProgramProject.objects.get().project.draft) def test_apply_project_rejects_field_from_another_program(self): create_program_member(self.program, user=self.user) diff --git a/partner_programs/tests/test_project_field_values_api.py b/partner_programs/tests/test_project_field_values_api.py index b27fc750..c2dbeca8 100644 --- a/partner_programs/tests/test_project_field_values_api.py +++ b/partner_programs/tests/test_project_field_values_api.py @@ -36,6 +36,25 @@ def test_project_leader_can_update_program_field_values(self): ) self.assertEqual(value.value_text, "ai") + def test_file_answer_remains_readable_in_project_detail(self): + field = create_program_field(self.program, name="document", field_type="file") + self.client.force_authenticate(self.leader) + url = "https://example.com/model.pdf" + self.assertEqual( + self.client.put( + f"/programs/partner-program-projects/{self.program_link.pk}/fields/", + [{"field_id": field.pk, "value_text": url}], + format="json", + ).status_code, + 200, + ) + response = self.client.get(f"/projects/{self.project.pk}/") + self.assertEqual(response.status_code, 200, response.data) + self.assertIn( + {"field_name": "document", "value": url}, + response.data["partner_program"]["program_field_values"], + ) + def test_non_leader_cannot_update_program_field_values(self): outsider = create_user(prefix="program-fields-outsider") self.client.force_authenticate(outsider) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 7c8c35f4..8fade1ff 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -32,6 +32,7 @@ from partner_programs.management_views import ( ManagementView, + ManagementProjectFormView, ManagementParticipantsView, ManagementProjectsView, ManagementTeamView, @@ -44,6 +45,11 @@ app_name = "partner_programs" urlpatterns = [ + path( + "/management/project-form/", + ManagementProjectFormView.as_view(), + name="management-project-form", + ), path("/management/", ManagementView.as_view(), name="management"), path( "/management/participants/",