Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/program-management-project-form.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions partner_programs/serializers/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 1 addition & 2 deletions partner_programs/serializers/programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
240 changes: 240 additions & 0 deletions partner_programs/services/management_form.py
Original file line number Diff line number Diff line change
@@ -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)
34 changes: 5 additions & 29 deletions partner_programs/services/project_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -133,15 +107,16 @@ 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

project_data = validated_data["project"]
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():
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading