From df0fc9e4589fb3dc668c0fbe5b61c855c0eb37e6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 14:25:22 +0300 Subject: [PATCH 1/4] feat: add Russian registration editor to Django admin --- AGENTS.md | 8 + .../partner_programs/admin_registration.css | 27 ++ assets/partner_programs/admin_registration.js | 77 +++ core/admin_language.py | 18 + docs/modules/partner-programs.md | 31 ++ partner_programs/admin.py | 46 +- partner_programs/admin_registration.py | 370 +++++++++++++++ .../0041_registration_admin_russian.py | 75 +++ partner_programs/registration_models.py | 38 +- partner_programs/registration_views.py | 27 +- .../services/native_registration.py | 29 ++ .../tests/test_registration_admin.py | 448 ++++++++++++++++++ procollab/settings.py | 1 + .../admin/program_manager_change_form.html | 7 + .../admin/programs_change_form.html | 7 + .../partner_programs/admin/registration.html | 109 +++++ .../admin/registration_connection.html | 7 + .../admin/registration_field.html | 12 + 18 files changed, 1296 insertions(+), 41 deletions(-) create mode 100644 assets/partner_programs/admin_registration.css create mode 100644 assets/partner_programs/admin_registration.js create mode 100644 core/admin_language.py create mode 100644 partner_programs/admin_registration.py create mode 100644 partner_programs/migrations/0041_registration_admin_russian.py create mode 100644 partner_programs/tests/test_registration_admin.py create mode 100644 templates/partner_programs/admin/registration.html create mode 100644 templates/partner_programs/admin/registration_connection.html create mode 100644 templates/partner_programs/admin/registration_field.html diff --git a/AGENTS.md b/AGENTS.md index 6ee28eaa..91c46531 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,11 @@ +# Возможности организатора + +Все действия организатора, доступные на фронте, должны дублироваться в Django admin. +Новые и изменяемые действия реализуйте в обоих интерфейсах через общую бизнес-логику +с одинаковыми проверками доступа и сохранности данных. Переход на фронт не заменяет +отсутствующее действие в админке. Все тексты интерфейса организатора и админки +должны быть на русском языке. + # Тексты интерфейса PROCOLLAB Во всех новых и изменяемых текстах интерфейса используйте Е/е вместо U+0401/U+0451. diff --git a/assets/partner_programs/admin_registration.css b/assets/partner_programs/admin_registration.css new file mode 100644 index 00000000..d604f5f2 --- /dev/null +++ b/assets/partner_programs/admin_registration.css @@ -0,0 +1,27 @@ +.registration-admin { max-width: 1000px; } +.registration-admin .module { margin-bottom: 24px; padding-bottom: 16px; } +.registration-admin .module > p, +.registration-admin .module > form, +.registration-admin .module > label { margin: 12px 16px; } +.registration-admin .registration-status { padding: 0 16px 16px; } +.registration-admin .registration-status h2 { margin: 0 -16px 16px; } +.registration-admin input[type="url"] { width: min(95%, 660px); } +.registration-field { border: 1px solid var(--hairline-color); border-radius: 4px; margin: 16px; padding: 12px; } +.registration-field legend { padding: 0 8px; font-weight: 600; } +.registration-admin .form-row label { display: block; float: none; width: auto; margin-bottom: 6px; } +.registration-admin .form-row input[type="checkbox"] + label { display: inline; } +.registration-admin textarea, +.registration-admin input[type="text"] { width: min(95%, 660px); box-sizing: border-box; } +.registration-admin button.button { padding: 10px 15px; border: 0; cursor: pointer; margin: 4px; } +.registration-admin button.default { background: var(--default-button-bg); } +.registration-admin .errorlist { margin: 12px 0; } +.registration-preview-body { padding: 16px; max-width: 760px; border: 1px solid var(--hairline-color); } +.registration-preview-body.mobile { max-width: 360px; } +.registration-preview-body label { display: block; margin-bottom: 8px; } +.registration-preview-body input, +.registration-preview-body select, +.registration-preview-body textarea { max-width: 100%; } +@media (max-width: 600px) { + .registration-field { margin: 10px 0; padding: 8px; } + .registration-admin .submit-row { display: flex; flex-wrap: wrap; gap: 8px; } +} diff --git a/assets/partner_programs/admin_registration.js b/assets/partner_programs/admin_registration.js new file mode 100644 index 00000000..2b07d3e5 --- /dev/null +++ b/assets/partner_programs/admin_registration.js @@ -0,0 +1,77 @@ +"use strict"; +document.addEventListener("DOMContentLoaded", () => { + const editor = document.getElementById("registration-editor"); + let dirty = false; + editor?.addEventListener("input", () => { dirty = true; }); + editor?.addEventListener("change", () => { dirty = true; }); + editor?.addEventListener("submit", () => { dirty = false; }); + window.addEventListener("beforeunload", event => { + if (dirty) { + event.preventDefault(); + event.returnValue = ""; + } + }); + const add = document.getElementById("add-registration-field"); + const total = document.getElementById("id_fields-TOTAL_FORMS"); + function updateField(row, changed) { + const type = row.querySelector('[name$="-type"]'); + const source = row.querySelector('[name$="-source"]'); + const list = ["select", "multiselect"].includes(type.value); + if (changed === type && !list) { + source.value = "own"; + row.querySelector('[name$="-allow_other"]').checked = false; + } + if ((changed === type && !list) || (changed === source && source.value !== "own")) { + row.querySelector('[name$="-options"]').value = ""; + row.querySelector('[name$="-options_file"]').value = ""; + } + for (const name of ["source", "options", "options_file", "allow_other"]) { + const ownOnly = ["options", "options_file"].includes(name); + row.querySelector(`[name$="-${name}"]`).closest(".form-row").hidden = + !list || (ownOnly && source.value !== "own"); + } + } + document.querySelectorAll(".registration-field").forEach(row => updateField(row)); + editor?.addEventListener("change", event => { + const row = event.target.closest(".registration-field"); + if (row) updateField(row, event.target); + }); + add?.addEventListener("click", event => { + event.preventDefault(); + const count = Number(total.value); + if (count >= 100) { + alert("В анкете может быть не более 100 полей."); + return; + } + const template = document.getElementById("registration-empty-field"); + const wrapper = document.createElement("div"); + wrapper.innerHTML = template.innerHTML.replaceAll("__prefix__", String(count)); + const row = wrapper.firstElementChild; + row.querySelector('[name$="-ORDER"]').value = count + 1; + document.querySelector("[data-empty-fields]")?.remove(); + document.getElementById("registration-fields").append(row); + updateField(row); + total.value = count + 1; + dirty = true; + row.querySelector('[name$="-label"]').focus(); + }); + document.querySelector("[data-copy-link]")?.addEventListener("click", async () => { + const link = document.getElementById("registration-link"); + const status = document.querySelector("[data-copy-status]"); + try { + await navigator.clipboard.writeText(link.value); + status.textContent = "Ссылка скопирована."; + } catch { + link.focus(); + link.select(); + status.textContent = "Скопируйте выделенную ссылку."; + } + }); + document.querySelector("[data-mobile-preview]")?.addEventListener("change", event => { + document.querySelector(".registration-preview-body").classList.toggle("mobile", event.target.checked); + }); + const preview = document.getElementById("registration-preview"); + if (preview) { + preview.focus(); + } +}); diff --git a/core/admin_language.py b/core/admin_language.py new file mode 100644 index 00000000..7152dda3 --- /dev/null +++ b/core/admin_language.py @@ -0,0 +1,18 @@ +"""Русский интерфейс админки независимо от языка публичного API.""" + +from django.urls import reverse +from django.utils import translation + + +class AdminRussianLanguageMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.path_info.startswith(reverse("admin:index")): + return self.get_response(request) + with translation.override("ru"): + request.LANGUAGE_CODE = "ru" + response = self.get_response(request) + response.setdefault("Content-Language", "ru") + return response diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index 35af76ba..fb0738fc 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -28,6 +28,37 @@ - публикация проектов после окончания программы; - новости программы через общий модуль `news`. +## Регистрация через админку + +Откройте **Партнерские программы → Программы → нужная программа → Регистрация**. +Создатель новой программы в админке автоматически добавляется в ее менеджеры. +Для существующих программ менеджеров можно назначить в карточке программы. + +В редакторе доступны те же настройки анкеты, что на фронте: встроенная или внешняя +регистрация, шесть типов вопросов, обязательность, подсказки, порядок, удаление, +свой список вариантов, справочники регионов и вузов, вариант «Другое», импорт +XLSX/CSV и предпросмотр на ширине компьютера или телефона. Редактировать JSON +для настройки анкеты не требуется. + +**Сохранить черновик** сохраняет изменения без замены действующей формы. +**Опубликовать форму** открывает новую версию участникам и показывает ссылку. +У программы в черновике кнопка **Опубликовать программу и форму** проверяет +описание и сроки и публикует их вместе. При ошибке изменения не сохраняются +частично. Для поля с ответами требуется подтверждение архивирования; ответы +остаются в карточках участников и выгрузках. + +Для внешнего сайта можно отправить запрос на подключение прямо здесь. +Администратор с правом изменения регистраций открывает **Настроить подключение +внешней формы**, указывает адрес и статус **Подключена**, после чего внешний +способ регистрации можно опубликовать. Сама ссылка не заменяет настройку интеграции. + +Редактор находится по адресу `/admin/partner_programs/partnerprogram//registration/` +и использует права изменения программы и ограничения списка программ в админке. +Сохранение, публикация, проверка версии и импорт используют общие сервисы с API; +устаревшая страница не перезаписывает изменения другого организатора. +Стандартные элементы и сообщения Django admin отображаются на русском языке; +язык публичного API от этого не меняется. + ## Архитектура - `partner_programs/models.py` - модели программ, участников, материалов, diff --git a/partner_programs/admin.py b/partner_programs/admin.py index f4a49b50..ff4bd8f8 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -4,6 +4,7 @@ import tablib from django import forms from django.contrib import admin +from django.db import transaction from . import solution_admin # noqa: F401 from projects.team_admin import TeamValidationAdminMixin from django.db.models import QuerySet @@ -27,6 +28,7 @@ ProgramFieldValueInlineFormSet, ) from partner_programs.services.case_fields import case_field_has_values +from partner_programs.admin_registration import ProgramRegistrationAdminMixin class PartnerProgramMaterialInline(admin.StackedInline): @@ -44,7 +46,7 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) -class PartnerProgramAdmin(admin.ModelAdmin): +class PartnerProgramAdmin(ProgramRegistrationAdminMixin, admin.ModelAdmin): class PartnerProgramAdminForm(forms.ModelForm): class Meta: model = PartnerProgram @@ -123,12 +125,23 @@ def get_queryset(self, request: HttpRequest) -> QuerySet[PartnerProgram]: if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True ): - user_programs: list[int] = request.user.expert.programs.values_list( - "id", flat=True - ) - qs = qs.filter(id__in=user_programs) + expert = getattr(request.user, "expert", None) + user_programs = expert.programs.values_list("id", flat=True) if expert else [] + from django.db.models import Q + + qs = qs.filter(Q(id__in=user_programs) | Q(managers=request.user)).distinct() return qs + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + def save_related(self, request, form, formsets, change): + super().save_related(request, form, formsets, change) + if not change: + form.instance.managers.add(request.user) + def change_view(self, request, object_id, form_url="", extra_context=None): if "Руководитель программы" in request.user.groups.all().values_list( "name", flat=True @@ -368,6 +381,7 @@ def get_deleted_objects(self, objs, request): @admin.register(ProgramRegistration) class ProgramRegistrationAdmin(admin.ModelAdmin): + change_form_template = "partner_programs/admin/registration_connection.html" list_display = ("program", "external_status", "requested_at", "published_at") list_filter = ("external_status",) search_fields = ( @@ -389,6 +403,28 @@ class ProgramRegistrationAdmin(admin.ModelAdmin): "connection_contact", ) + def get_queryset(self, request): + programs = self.admin_site._registry[PartnerProgram].get_queryset(request) + return super().get_queryset(request).filter(program__in=programs) + + def has_change_permission(self, request, obj=None): + return super().has_change_permission(request, obj) and ( + obj is None + or self.admin_site._registry[PartnerProgram].has_change_permission( + request, obj.program + ) + ) + + @transaction.atomic + def save_model(self, request, obj, form, change): + # Подключение формы участвует в той же блокировке и версии, что редакторы анкеты. + PartnerProgram.objects.select_for_update().get(pk=obj.program_id) + current = ProgramRegistration.objects.select_for_update().get(pk=obj.pk) + for name in self.readonly_fields: + setattr(obj, name, getattr(current, name)) + obj.revision = current.revision + 1 + super().save_model(request, obj, form, change) + def has_add_permission(self, request): return False diff --git a/partner_programs/admin_registration.py b/partner_programs/admin_registration.py new file mode 100644 index 00000000..43037366 --- /dev/null +++ b/partner_programs/admin_registration.py @@ -0,0 +1,370 @@ +"""Редактор анкеты в админке; схема и публикация общие с API организатора.""" + +from uuid import uuid4 + +from django import forms +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.forms import BaseFormSet, formset_factory +from django.http import Http404, HttpResponseRedirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from rest_framework.exceptions import APIException + +from partner_programs.services import native_registration as registration +from partner_programs.services import program_launch + + +FIELD_TYPES = ( + ("text", "Короткий текст"), + ("textarea", "Развернутый ответ"), + ("number", "Число"), + ("date", "Дата"), + ("select", "Один вариант из списка"), + ("multiselect", "Несколько вариантов"), +) +SOURCES = ( + ("own", "Свой список"), + ("universities", "Справочник вузов"), + ("regions", "Справочник регионов"), +) + + +def error_text(detail): + if isinstance(detail, dict): + # Служебные коды API не являются текстом интерфейса. + return " ".join(error_text(v) for k, v in detail.items() if k != "code") + if isinstance(detail, (list, tuple)): + return " ".join(error_text(v) for v in detail) + return str(detail) + + +class RegistrationForm(forms.Form): + revision = forms.IntegerField(min_value=0, widget=forms.HiddenInput) + mode = forms.ChoiceField( + label="Способ регистрации", + choices=(("native", "Через PROCOLLAB"), ("external", "Через внешний сайт")), + widget=forms.RadioSelect, + ) + confirm_archive = forms.BooleanField( + label="Подтверждаю удаление полей из новой анкеты с сохранением прежних ответов", + required=False, + ) + + +class RegistrationFieldForm(forms.Form): + id = forms.RegexField(r"^f[a-f0-9]{32}$", required=False, widget=forms.HiddenInput) + label = forms.CharField(label="Название поля", max_length=255) + type = forms.ChoiceField(label="Тип ответа", choices=FIELD_TYPES) + help_text = forms.CharField( + label="Подсказка участнику", required=False, max_length=1000 + ) + required = forms.BooleanField(label="Обязательное поле", required=False) + source = forms.ChoiceField(label="Источник вариантов", choices=SOURCES, initial="own") + options = forms.CharField( + label="Варианты ответа", + required=False, + widget=forms.Textarea(attrs={"rows": 4}), + help_text="По одному варианту на строке. Используется для своего списка.", + ) + options_file = forms.FileField( + label="Импортировать варианты из файла", + required=False, + help_text="XLSX или CSV в UTF-8: один столбец без заголовка, до 2 МБ.", + widget=forms.ClearableFileInput(attrs={"accept": ".xlsx,.csv"}), + ) + allow_other = forms.BooleanField(label="Разрешить вариант «Другое»", required=False) + + def clean(self): + data = super().clean() + data["id"] = data.get("id") or "f" + uuid4().hex + options = list( + dict.fromkeys( + v.strip() for v in data.get("options", "").splitlines() if v.strip() + ) + ) + upload = data.get("options_file") + if upload: + if ( + data.get("type") not in ("select", "multiselect") + or data.get("source") != "own" + ): + self.add_error( + "options_file", "Импорт доступен для поля со своим списком вариантов." + ) + else: + try: + result = registration.import_options(upload) + options = result["options"] + self.import_result = result + except APIException as error: + self.add_error("options_file", error_text(error.detail)) + data["options"] = options + return data + + +class RegistrationFieldFormSet(BaseFormSet): + def add_fields(self, form, index): + super().add_fields(form, index) + form.fields["ORDER"].label = "Позиция в анкете" + form.fields["ORDER"].widget.attrs["min"] = 1 + form.fields["DELETE"].label = "Убрать поле из новой анкеты" + + def schema(self, mode, *, publishing=False): + fields = [ + { + key: form.cleaned_data[key] + for key in ( + "id", + "label", + "type", + "help_text", + "required", + "source", + "options", + "allow_other", + ) + } + for form in self.ordered_forms + ] + return registration.clean_schema( + {"mode": mode, "fields": fields}, publishing=publishing + ) + + +FieldFormSet = formset_factory( + RegistrationFieldForm, + formset=RegistrationFieldFormSet, + extra=0, + can_order=True, + can_delete=True, + max_num=100, + validate_max=True, + absolute_max=200, +) + + +class ConnectionForm(forms.Form): + url = forms.URLField(label="Сайт мероприятия", max_length=200) + comment = forms.CharField( + label="Комментарий", + required=False, + max_length=2000, + widget=forms.Textarea(attrs={"rows": 3}), + ) + contact = forms.CharField(label="Контакт для связи", required=False, max_length=500) + + def clean_url(self): + value = self.cleaned_data["url"] + if not value.startswith(("http://", "https://")): + raise forms.ValidationError( + "Укажите адрес сайта, начинающийся с http:// или https://." + ) + return value + + +def field_initial(fields): + return [{**field, "options": "\n".join(field["options"])} for field in fields] + + +def preview_form(schema): + from partner_programs.registration_regions import REGIONS + from users.models import University + + form = forms.Form() + for field in schema["fields"]: + common = dict( + label=field["label"], + help_text=field["help_text"], + required=field["required"], + disabled=True, + ) + kind = field["type"] + if kind in ("select", "multiselect"): + options = field["options"] + if field["source"] == "regions": + options = REGIONS + elif field["source"] == "universities": + options = University.objects.filter(is_active=True).values_list( + "name", flat=True + ) + choices = [(v, v) for v in options] + if field["allow_other"]: + choices.append(("__other__", "Другое")) + cls = ( + forms.MultipleChoiceField if kind == "multiselect" else forms.ChoiceField + ) + item = cls(choices=choices, **common) + elif kind == "number": + item = forms.FloatField(**common) + elif kind == "date": + item = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), **common + ) + else: + item = forms.CharField( + widget=forms.Textarea if kind == "textarea" else forms.TextInput, **common + ) + form.fields[field["id"]] = item + return form + + +class ProgramRegistrationAdminMixin: + def get_urls(self): + return [ + path( + "/registration/", + self.admin_site.admin_view(self.registration_view), + name="partner_programs_partnerprogram_registration", + ) + ] + super().get_urls() + + def registration_view(self, request, object_id): + program = self.get_object(request, str(object_id)) + if program is None: + raise Http404 + if not self.has_change_permission(request, program): + raise PermissionDenied + state = registration.settings_data(program) + initial = {"revision": state["revision"], "mode": state["draft"]["mode"]} + bound = ( + request.method == "POST" + and request.POST.get("action") != "request_connection" + ) + form = RegistrationForm(request.POST if bound else None, initial=initial) + fields = FieldFormSet( + request.POST if bound else None, + request.FILES if bound else None, + initial=field_initial(state["draft"]["fields"]), + prefix="fields", + ) + connection = ConnectionForm( + initial={ + "url": state["connection_website_url"], + "comment": state["connection_comment"], + "contact": state["connection_contact"], + } + ) + preview = None + if request.method == "POST": + action = request.POST.get("action") + if action == "request_connection": + connection = ConnectionForm(request.POST) + if connection.is_valid(): + try: + registration.request_connection( + program.pk, request.user, connection.cleaned_data + ) + except APIException as error: + connection.add_error(None, error_text(error.detail)) + else: + self.message_user( + request, "Запрос на подключение отправлен администратору." + ) + return HttpResponseRedirect(request.path) + elif form.is_valid() and fields.is_valid(): + try: + schema = fields.schema( + form.cleaned_data["mode"], publishing=action == "publish" + ) + if action in ("save", "publish"): + removed = set(state["answered_field_ids"]) & ( + {f["id"] for f in state["draft"]["fields"]} + - {f["id"] for f in schema["fields"]} + ) + if removed and not form.cleaned_data["confirm_archive"]: + form.add_error( + "confirm_archive", + "На удаляемые поля уже есть ответы. Подтвердите их архивирование.", + ) + else: + with transaction.atomic(): + # Проверка версии и блокировка те же, что при сохранении с фронта. + registration.save_settings( + program.pk, + request.user, + { + "revision": form.cleaned_data["revision"], + "draft": schema, + }, + publish=action == "publish", + ) + current = program_launch.readiness( + program.pk, request.user + ) + if action == "publish" and current["program"]["draft"]: + program_launch.publish_program( + program.pk, request.user, current["revision"] + ) + self.log_change( + request, + program, + "Опубликована форма регистрации." + if action == "publish" + else "Сохранен черновик регистрации.", + ) + self.message_user( + request, + "Форма регистрации опубликована." + if action == "publish" + else "Черновик регистрации сохранен.", + ) + return HttpResponseRedirect(request.path) + elif action in ("preview", "add", "import"): + if action == "preview": + preview = preview_form(schema) + rows = field_initial(schema["fields"]) + if action == "add": + if len(rows) >= 100: + form.add_error( + None, "В анкете может быть не более 100 полей." + ) + else: + rows.append( + { + "id": "f" + uuid4().hex, + "type": "text", + "source": "own", + } + ) + for item in fields: + if hasattr(item, "import_result"): + result = item.import_result + self.message_user( + request, + f"Импортировано вариантов: {len(result['options'])}. " + f"Повторов: {result['duplicates']}. " + f"Пустых строк: {result['blanks']}.", + messages.INFO, + ) + fields = FieldFormSet(initial=rows, prefix="fields") + else: + form.add_error(None, "Выберите действие с формой регистрации.") + except APIException as error: + form.add_error(None, error_text(error.detail)) + config = registration.config_for(program) + context = { + **self.admin_site.each_context(request), + "title": f"Регистрация: {program.name}", + "opts": self.model._meta, + "original": program, + "program": program, + "form": form, + "fields": fields, + "connection": connection, + "preview": preview, + "state": state, + "change_url": reverse( + "admin:partner_programs_partnerprogram_change", args=[program.pk] + ), + "connection_url": reverse( + "admin:partner_programs_programregistration_change", args=[config.pk] + ) + if config + and request.user.has_perm("partner_programs.change_programregistration") + else None, + } + return TemplateResponse( + request, "partner_programs/admin/registration.html", context + ) diff --git a/partner_programs/migrations/0041_registration_admin_russian.py b/partner_programs/migrations/0041_registration_admin_russian.py new file mode 100644 index 00000000..230a264d --- /dev/null +++ b/partner_programs/migrations/0041_registration_admin_russian.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.11 on 2026-10-27 11:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('partner_programs', '0040_program_creation_publication'), + ] + + operations = [ + migrations.AlterModelOptions( + name='programregistration', + options={'verbose_name': 'Регистрация программы', 'verbose_name_plural': 'Регистрации программ'}, + ), + migrations.AlterField( + model_name='programregistration', + name='connection_comment', + field=models.TextField(blank=True, verbose_name='Комментарий к подключению'), + ), + migrations.AlterField( + model_name='programregistration', + name='draft', + field=models.JSONField(default=dict, verbose_name='Черновик анкеты'), + ), + migrations.AlterField( + model_name='programregistration', + name='external_status', + field=models.CharField(choices=[('not_connected', 'Не подключена'), ('requested', 'Запрос отправлен'), ('connected', 'Подключена')], default='not_connected', max_length=20, verbose_name='Статус подключения внешней формы'), + ), + migrations.AlterField( + model_name='programregistration', + name='external_url', + field=models.URLField(blank=True, verbose_name='Адрес подключенной формы'), + ), + migrations.AlterField( + model_name='programregistration', + name='program', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='registration_config', to='partner_programs.partnerprogram', verbose_name='Программа'), + ), + migrations.AlterField( + model_name='programregistration', + name='published', + field=models.JSONField(blank=True, editable=False, null=True, verbose_name='Опубликованная анкета'), + ), + migrations.AlterField( + model_name='programregistration', + name='published_at', + field=models.DateTimeField(editable=False, null=True, verbose_name='Дата публикации'), + ), + migrations.AlterField( + model_name='programregistration', + name='published_version', + field=models.PositiveIntegerField(default=0, editable=False, verbose_name='Версия опубликованной анкеты'), + ), + migrations.AlterField( + model_name='programregistration', + name='requested_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Дата запроса подключения'), + ), + migrations.AlterField( + model_name='programregistration', + name='requested_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Кто запросил подключение'), + ), + migrations.AlterField( + model_name='programregistration', + name='revision', + field=models.PositiveIntegerField(default=0, editable=False, verbose_name='Версия настроек'), + ), + ] diff --git a/partner_programs/registration_models.py b/partner_programs/registration_models.py index e82d60e6..338814c5 100644 --- a/partner_programs/registration_models.py +++ b/partner_programs/registration_models.py @@ -13,25 +13,43 @@ class ProgramRegistration(models.Model): "partner_programs.PartnerProgram", on_delete=models.CASCADE, related_name="registration_config", + verbose_name="Программа", ) - draft = models.JSONField(default=dict) - published = models.JSONField(null=True, blank=True, editable=False) - revision = models.PositiveIntegerField(default=0, editable=False) - published_version = models.PositiveIntegerField(default=0, editable=False) - published_at = models.DateTimeField(null=True, editable=False) + draft = models.JSONField("Черновик анкеты", default=dict) + published = models.JSONField( + "Опубликованная анкета", null=True, blank=True, editable=False + ) + revision = models.PositiveIntegerField("Версия настроек", default=0, editable=False) + published_version = models.PositiveIntegerField( + "Версия опубликованной анкеты", default=0, editable=False + ) + published_at = models.DateTimeField("Дата публикации", null=True, editable=False) external_status = models.CharField( + "Статус подключения внешней формы", max_length=20, default="not_connected", - choices=[(v, v) for v in ("not_connected", "requested", "connected")], + choices=[ + ("not_connected", "Не подключена"), + ("requested", "Запрос отправлен"), + ("connected", "Подключена"), + ], ) - external_url = models.URLField(blank=True) + external_url = models.URLField("Адрес подключенной формы", blank=True) connection_website_url = models.URLField("Сайт мероприятия", blank=True) - connection_comment = models.TextField(blank=True) + connection_comment = models.TextField("Комментарий к подключению", blank=True) connection_contact = models.CharField("Контакт для связи", max_length=500, blank=True) requested_by = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="Кто запросил подключение", ) - requested_at = models.DateTimeField(null=True, blank=True) + requested_at = models.DateTimeField("Дата запроса подключения", null=True, blank=True) + + class Meta: + verbose_name = "Регистрация программы" + verbose_name_plural = "Регистрации программ" def clean(self): super().clean() diff --git a/partner_programs/registration_views.py b/partner_programs/registration_views.py index 31351d90..067768ff 100644 --- a/partner_programs/registration_views.py +++ b/partner_programs/registration_views.py @@ -1,6 +1,5 @@ from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import transaction from django.utils import timezone from rest_framework import serializers from rest_framework.permissions import AllowAny @@ -132,33 +131,9 @@ class ConnectionWrite(RegistrationInput): class RegistrationConnectionView(ManagementBase): - @transaction.atomic def post(self, request, pk): data = validated(ConnectionWrite, request.data) - program = managed_program(pk, request.user, lock=True) - config = service.locked_config(program) - if config.external_status != "connected": - duplicate = ( - config.external_status == "requested" - and config.connection_website_url == data["url"] - and config.connection_comment == data["comment"] - and config.connection_contact == data["contact"] - and config.connection_requests.exists() - ) - if duplicate: - return Response(service.settings_data(program)) - config.connection_website_url = data["url"] - config.connection_comment = data["comment"] - config.connection_contact = data["contact"] - config.requested_by = request.user - config.requested_at = timezone.now() - config.external_status = "requested" - config.revision += 1 - config.save() - from partner_programs.services.registration_notifications import record_request - - record_request(config, request.user) - return Response(service.settings_data(program)) + return Response(service.request_connection(pk, request.user, data)) class RegistrationThrottle(AnonRateThrottle): diff --git a/partner_programs/services/native_registration.py b/partner_programs/services/native_registration.py index 47068711..6224094a 100644 --- a/partner_programs/services/native_registration.py +++ b/partner_programs/services/native_registration.py @@ -199,6 +199,35 @@ def save_settings(pk, user, data, *, publish=False): return settings_data(program) +@transaction.atomic +def request_connection(pk, user, data): + program = managed_program(pk, user, lock=True) + config = locked_config(program) + if config.external_status != "connected": + duplicate = ( + config.external_status == "requested" + and config.connection_website_url == data["url"] + and config.connection_comment == data["comment"] + and config.connection_contact == data["contact"] + and config.connection_requests.exists() + ) + if not duplicate: + config.connection_website_url = data["url"] + config.connection_comment = data["comment"] + config.connection_contact = data["contact"] + config.requested_by = user + config.requested_at = timezone.now() + config.external_status = "requested" + config.revision += 1 + config.save() + from partner_programs.services.registration_notifications import ( + record_request, + ) + + record_request(config, user) + return settings_data(program) + + def open_program(pk, *, lock=False): rows = PartnerProgram.objects.select_for_update() if lock else PartnerProgram.objects return get_object_or_404(rows, pk=pk, draft=False) diff --git a/partner_programs/tests/test_registration_admin.py b/partner_programs/tests/test_registration_admin.py new file mode 100644 index 00000000..f92dc82d --- /dev/null +++ b/partner_programs/tests/test_registration_admin.py @@ -0,0 +1,448 @@ +"""Реальные запросы к админке и чтение той же анкеты через публичное API.""" + +import copy +import io + +from django.contrib.auth.models import Group, Permission +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.urls import reverse +from openpyxl import Workbook +from rest_framework.test import APIClient + +from partner_programs.models import ProgramRegistration, PartnerProgramUserProfile +from partner_programs.services import native_registration as registration +from partner_programs.tests.helpers import create_partner_program, create_user + + +@override_settings( + STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage" +) +class RegistrationAdminTests(TestCase): + def setUp(self): + self.admin = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(self.admin) + self.program = create_partner_program(name="Программа из админки") + self.url = reverse( + "admin:partner_programs_partnerprogram_registration", args=[self.program.pk] + ) + self.field = { + "id": "f" + "a" * 32, + "label": "Ваш вуз", + "type": "select", + "help_text": "Выберите вуз", + "required": True, + "source": "own", + "options": ["Первый вуз", "Второй вуз"], + "allow_other": True, + } + + def payload(self, *, fields=None, revision=0, action="save", mode="native"): + fields = fields if fields is not None else [self.field] + data = { + "mode": mode, + "revision": revision, + "action": action, + "fields-TOTAL_FORMS": len(fields), + "fields-INITIAL_FORMS": 0, + } + for index, field in enumerate(fields): + for key, value in field.items(): + if isinstance(value, bool): + if value: + data[f"fields-{index}-{key}"] = "on" + else: + data[f"fields-{index}-{key}"] = ( + "\n".join(value) if isinstance(value, list) else value + ) + data[f"fields-{index}-ORDER"] = index + 1 + return data + + def test_program_card_links_to_russian_editor_without_creating_config(self): + page = self.client.get( + reverse( + "admin:partner_programs_partnerprogram_change", args=[self.program.pk] + ) + ) + self.assertContains(page, self.url) + page = self.client.get(self.url) + for label in ( + "Способ регистрации", + "Добавить поле", + "Опубликовать форму", + "Короткий текст", + "Справочник вузов", + ): + self.assertContains(page, label) + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_create_draft_then_publish_and_enroll_via_public_api(self): + self.assertEqual(self.client.post(self.url, self.payload()).status_code, 302) + config = ProgramRegistration.objects.get(program=self.program) + self.assertIsNone(config.published) + api = APIClient() + api.force_authenticate(self.admin) + state = api.get(f"/programs/{self.program.pk}/management/registration/").data + self.assertEqual(state["draft"]["fields"], [self.field]) + self.assertEqual( + self.client.post( + self.url, self.payload(revision=1, action="publish") + ).status_code, + 302, + ) + config.refresh_from_db() + self.assertEqual(config.published_version, 1) + self.assertEqual(config.published["fields"], [self.field]) + public = api.get(f"/programs/{self.program.pk}/registration/") + self.assertEqual(public.status_code, 200, public.data) + member = create_user(is_active=True) + api.force_authenticate(member) + response = api.post( + f"/programs/{self.program.pk}/registration/", + { + "version": config.published_version, + "answers": {self.field["id"]: "Первый вуз"}, + }, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue( + PartnerProgramUserProfile.objects.filter( + partner_program=self.program, user=member + ).exists() + ) + + def test_empty_questionnaire_can_publish(self): + self.assertEqual( + self.client.post( + self.url, self.payload(fields=[], action="publish") + ).status_code, + 302, + ) + self.assertEqual( + ProgramRegistration.objects.get(program=self.program).published, + {"mode": "native", "fields": []}, + ) + + def test_all_field_types_and_directory_sources_publish_without_json_editing(self): + fields = [] + for index, kind in enumerate( + ("text", "textarea", "number", "date", "select", "multiselect") + ): + fields.append( + { + **self.field, + "id": "f" + str(index) * 32, + "type": kind, + "source": "regions" + if kind == "select" + else "universities" + if kind == "multiselect" + else "own", + "options": [], + "allow_other": kind in ("select", "multiselect"), + } + ) + self.assertEqual( + self.client.post( + self.url, self.payload(fields=fields, action="publish") + ).status_code, + 302, + ) + self.assertEqual( + ProgramRegistration.objects.get(program=self.program).published["fields"], + fields, + ) + + def test_more_than_100_fields_cannot_be_saved(self): + fields = [{**self.field, "id": "f" + format(i, "032x")} for i in range(101)] + page = self.client.post(self.url, self.payload(fields=fields)) + self.assertEqual(page.status_code, 200) + self.assertTrue(page.context["fields"].non_form_errors()) + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_preview_and_add_do_not_save_or_enroll(self): + page = self.client.post(self.url, self.payload(action="preview")) + self.assertContains(page, "Предпросмотр анкеты участника") + self.assertContains(page, "Первый вуз") + self.assertContains(page, "disabled") + self.assertFalse(ProgramRegistration.objects.exists()) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + page = self.client.post(self.url, self.payload(action="add")) + self.assertEqual(len(page.context["fields"].forms), 2) + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_stale_admin_form_cannot_overwrite_frontend_update(self): + registration.save_settings( + self.program.pk, + self.admin, + { + "revision": 0, + "draft": {"mode": "native", "fields": []}, + }, + ) + page = self.client.post(self.url, self.payload(action="publish")) + self.assertContains(page, "Анкета изменена другим организатором") + config = ProgramRegistration.objects.get(program=self.program) + self.assertEqual(config.revision, 1) + self.assertEqual(config.draft["fields"], []) + self.assertIsNone(config.published) + + def test_missing_options_can_save_but_not_publish(self): + field = {**self.field, "options": []} + self.assertEqual( + self.client.post(self.url, self.payload(fields=[field])).status_code, 302 + ) + page = self.client.post( + self.url, self.payload(fields=[field], revision=1, action="publish") + ) + self.assertContains(page, "Добавьте варианты ответа") + config = ProgramRegistration.objects.get(program=self.program) + self.assertIsNone(config.published) + self.assertEqual(config.revision, 1) + + def test_duplicate_field_ids_and_invalid_sources_are_rejected(self): + for fields in ([self.field, self.field], [{**self.field, "source": "regions"}]): + with self.subTest(fields=fields): + self.assertEqual( + self.client.post(self.url, self.payload(fields=fields)).status_code, + 200, + ) + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_reorder_and_delete_preserve_published_snapshot_and_answers(self): + other = {**self.field, "id": "f" + "b" * 32, "label": "Второй вопрос"} + schema = {"mode": "native", "fields": [self.field, other]} + config = ProgramRegistration.objects.create( + program=self.program, + draft=schema, + published=copy.deepcopy(schema), + published_version=1, + ) + member = create_user() + profile = PartnerProgramUserProfile.objects.create( + partner_program=self.program, + user=member, + partner_program_data={self.field["id"]: "Первый вуз"}, + registration_schema=copy.deepcopy(schema), + ) + data = self.payload(fields=[self.field, other]) + data["fields-0-DELETE"] = "on" + page = self.client.post(self.url, data) + self.assertContains(page, "Подтвердите их архивирование") + config.refresh_from_db() + self.assertEqual(config.revision, 0) + data["confirm_archive"] = "on" + self.assertEqual(self.client.post(self.url, data).status_code, 302) + config.refresh_from_db() + self.assertEqual(config.draft["fields"], [other]) + self.assertEqual(config.published, schema) + profile.refresh_from_db() + self.assertEqual(profile.partner_program_data, {self.field["id"]: "Первый вуз"}) + self.assertEqual(profile.registration_schema, schema) + data = self.payload(fields=[self.field, other], revision=1) + data["fields-0-ORDER"], data["fields-1-ORDER"] = 2, 1 + self.assertEqual(self.client.post(self.url, data).status_code, 302) + config.refresh_from_db() + self.assertEqual( + [f["id"] for f in config.draft["fields"]], [other["id"], self.field["id"]] + ) + + def test_csv_and_xlsx_import_preview_and_save(self): + book = Workbook() + for value in ("Первый", "Второй", "Первый"): + book.active.append([value]) + stream = io.BytesIO() + book.save(stream) + for filename, content in ( + ("варианты.csv", "Первый\nВторой\nПервый".encode()), + ("варианты.xlsx", stream.getvalue()), + ): + with self.subTest(filename=filename): + data = self.payload(action="import") + data["fields-0-options_file"] = SimpleUploadedFile(filename, content) + page = self.client.post(self.url, data) + self.assertEqual( + page.context["fields"].forms[0].initial["options"], "Первый\nВторой" + ) + self.assertContains(page, "Импортировано вариантов: 2") + self.assertFalse(ProgramRegistration.objects.exists()) + data = self.payload() + data["fields-0-options_file"] = SimpleUploadedFile("варианты.csv", b"A\nB") + self.assertEqual(self.client.post(self.url, data).status_code, 302) + self.assertEqual( + ProgramRegistration.objects.get(program=self.program).draft["fields"][0][ + "options" + ], + ["A", "B"], + ) + + def test_invalid_import_does_not_save(self): + data = self.payload() + data["fields-0-options_file"] = SimpleUploadedFile("variants.csv", b"A,B\nC,D") + self.assertContains(self.client.post(self.url, data), "Не удалось прочитать файл") + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_draft_program_and_form_publish_together_or_roll_back(self): + self.program.draft = True + self.program.description = "" + self.program.save() + page = self.client.post(self.url, self.payload(action="publish")) + self.assertContains(page, "Заполните название и описание программы") + self.assertFalse(ProgramRegistration.objects.exists()) + self.program.refresh_from_db() + self.assertTrue(self.program.draft) + self.program.description = "Описание программы" + self.program.save() + self.assertEqual( + self.client.post(self.url, self.payload(action="publish")).status_code, 302 + ) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + self.assertEqual( + ProgramRegistration.objects.get(program=self.program).published_version, 1 + ) + + def test_external_form_requires_connection_and_then_publishes(self): + page = self.client.post( + self.url, self.payload(fields=[], mode="external", action="publish") + ) + self.assertContains(page, "Дождитесь подключения внешней формы") + self.assertFalse(ProgramRegistration.objects.exists()) + ProgramRegistration.objects.create( + program=self.program, + draft={"mode": "external", "fields": []}, + external_status="connected", + external_url="https://example.test/register", + ) + self.assertEqual( + self.client.post( + self.url, self.payload(fields=[], mode="external", action="publish") + ).status_code, + 302, + ) + page = self.client.get(self.url) + self.assertContains(page, "https://example.test/register") + + def test_connection_request_is_shared_with_frontend_and_deduplicated(self): + data = { + "action": "request_connection", + "url": "https://example.test", + "contact": "Контакт", + "comment": "Подключите форму", + } + self.assertEqual(self.client.post(self.url, data).status_code, 302) + self.assertEqual(self.client.post(self.url, data).status_code, 302) + config = ProgramRegistration.objects.get(program=self.program) + self.assertEqual(config.external_status, "requested") + self.assertEqual(config.connection_requests.count(), 1) + self.assertEqual(config.revision, 1) + + def test_staff_without_permission_and_non_staff_cannot_edit(self): + for staff, status in ((True, 403), (False, 302)): + user = create_user(is_active=True, is_staff=staff) + self.client.force_login(user) + self.assertEqual( + self.client.post(self.url, self.payload()).status_code, status + ) + self.assertFalse(ProgramRegistration.objects.exists()) + + def test_program_manager_group_can_only_edit_allowed_programs(self): + manager = create_user(is_staff=True, is_active=True) + manager.groups.add(Group.objects.create(name="Руководитель программы")) + manager.user_permissions.add( + Permission.objects.get(codename="change_partnerprogram") + ) + self.program.managers.add(manager) + other = create_partner_program() + self.client.force_login(manager) + self.assertEqual(self.client.get(self.url).status_code, 200) + self.assertEqual( + self.client.post(self.url, self.payload(action="publish")).status_code, 302 + ) + other_url = reverse( + "admin:partner_programs_partnerprogram_registration", args=[other.pk] + ) + self.assertEqual(self.client.post(other_url, self.payload()).status_code, 404) + + def test_csrf_required_for_editor(self): + from django.test import Client + + client = Client(enforce_csrf_checks=True) + client.force_login(self.admin) + self.assertEqual(client.post(self.url, self.payload()).status_code, 403) + + def test_connection_admin_is_russian_and_advances_editor_revision(self): + config = ProgramRegistration.objects.create( + program=self.program, draft={"mode": "external", "fields": []} + ) + url = reverse( + "admin:partner_programs_programregistration_change", args=[config.pk] + ) + page = self.client.get(url) + for text in ( + "Регистрация программы", + "Статус подключения внешней формы", + "Не подключена", + "Редактировать анкету регистрации", + ): + self.assertContains(page, text) + response = self.client.post( + url, + { + "external_status": "connected", + "external_url": "https://example.test/register", + "connection_comment": "", + "_save": "Сохранить", + }, + ) + self.assertEqual(response.status_code, 302) + config.refresh_from_db() + self.assertEqual(config.revision, 1) + self.assertContains( + self.client.post(self.url, self.payload(action="publish")), + "Анкета изменена другим организатором", + ) + + def test_admin_language_is_russian_and_public_api_keeps_its_language(self): + from django.utils import translation + + with translation.override("en"): + page = self.client.get(self.url) + self.assertEqual(page["Content-Language"], "ru") + self.assertContains(page, 'lang="ru"') + data = self.payload() + data["fields-0-label"] = "" + self.assertContains(self.client.post(self.url, data), "Обязательное поле") + self.assertEqual(translation.get_language(), "en") + public = self.client.get(f"/programs/{self.program.pk}/registration/") + self.assertNotEqual(public.get("Content-Language"), "ru") + + def test_new_program_created_in_admin_assigns_creator_as_manager(self): + from partner_programs.models import PartnerProgram + + url = reverse("admin:partner_programs_partnerprogram_add") + page = self.client.get(url) + data = { + "name": "Создана администратором", + "tag": "test", + "description": "Описание", + "city": "Москва", + "projects_availability": "all_users", + "data_schema": '{"extra": {"name": "Поле"}}', + "draft": "on", + "_save": "Сохранить", + } + for inline in page.context["inline_admin_formsets"]: + prefix = inline.formset.prefix + data.update({f"{prefix}-TOTAL_FORMS": 0, f"{prefix}-INITIAL_FORMS": 0}) + response = self.client.post(url, data) + errors = ( + response.context["adminform"].form.errors + if response.status_code == 200 + else None + ) + self.assertEqual(response.status_code, 302, errors) + program = PartnerProgram.objects.get(name=data["name"]) + self.assertEqual(program.created_by, self.admin) + self.assertTrue(program.managers.filter(pk=self.admin.pk).exists()) diff --git a/procollab/settings.py b/procollab/settings.py index e1ee3fd9..edaa9540 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -107,6 +107,7 @@ "corsheaders.middleware.CorsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "core.admin_language.AdminRussianLanguageMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", diff --git a/templates/partner_programs/admin/program_manager_change_form.html b/templates/partner_programs/admin/program_manager_change_form.html index 874e6b33..9ac16db1 100644 --- a/templates/partner_programs/admin/program_manager_change_form.html +++ b/templates/partner_programs/admin/program_manager_change_form.html @@ -1,6 +1,13 @@ {% extends "admin/change_form.html" %} {% load i18n %} +{% block object-tools-items %} + {% if original and has_change_permission %} +
  • Регистрация
  • + {% endif %} + {{ block.super }} +{% endblock %} + {% block submit_buttons_bottom %} diff --git a/templates/partner_programs/admin/programs_change_form.html b/templates/partner_programs/admin/programs_change_form.html index f347f26f..becea89e 100644 --- a/templates/partner_programs/admin/programs_change_form.html +++ b/templates/partner_programs/admin/programs_change_form.html @@ -1,6 +1,13 @@ {% extends "admin/change_form.html" %} {% load i18n %} +{% block object-tools-items %} + {% if original and has_change_permission %} +
  • Регистрация
  • + {% endif %} + {{ block.super }} +{% endblock %} + {% block submit_buttons_bottom %} diff --git a/templates/partner_programs/admin/registration.html b/templates/partner_programs/admin/registration.html new file mode 100644 index 00000000..17742663 --- /dev/null +++ b/templates/partner_programs/admin/registration.html @@ -0,0 +1,109 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} +{% block extrahead %}{{ block.super }} + +{% endblock %} +{% block breadcrumbs %} + +{% endblock %} +{% block content %} +
    +

    Вернуться к программе

    +
    +

    Страница регистрации

    + {% if program.draft %} +

    Программа в черновике. Кнопка «Опубликовать программу и форму» откроет программу и регистрацию после проверки названия, описания и сроков.

    + {% elif state.published %} +

    Форма опубликована. Версия: {{ state.published_version }}. Сохранение черновика не меняет действующую форму.

    + {% else %} +

    Форма еще не опубликована. Настройте анкету, проверьте ее и опубликуйте.

    + {% endif %} + {% if state.link %} + + + + Открыть страницу регистрации + + {% endif %} +
    +
    + {% csrf_token %} + {{ form.revision }} + {{ form.non_field_errors }} + {{ form.revision.errors }} +
    +

    Способ регистрации

    +
    {{ form.mode.errors }}{{ form.mode }}
    +

    Через PROCOLLAB: анкета, аккаунт и участие в одном месте. Через внешний сайт: требуется подключенная внешняя форма.

    +
    +
    +

    Аккаунт PROCOLLAB

    +

    Для нового пользователя: имя, фамилия, электронная почта, дата рождения, пароль и согласия. У вошедшего участника повторно их не запрашиваем.

    +
    +
    +

    Анкета участника

    +

    Для вопросов с выбором ответа укажите свой список или общий справочник. Позиция задает порядок полей в анкете.

    +

    Если на убираемое поле уже есть ответы, они сохранятся в карточках участников и выгрузке. Изменения станут доступны участникам после публикации.

    + {{ fields.management_form }} + {{ fields.non_form_errors }} +
    + {% for field_form in fields %} + {% include "partner_programs/admin/registration_field.html" %} + {% empty %} +

    Дополнительных вопросов пока нет. Можно опубликовать регистрацию без них.

    + {% endfor %} +
    + + +

    Импорт заменяет варианты в выбранном поле. Для сохранения анкеты нажмите «Сохранить черновик» или кнопку публикации.

    +
    {{ form.confirm_archive.errors }}{{ form.confirm_archive }} {{ form.confirm_archive.label_tag }}
    +
    +
    + + + +
    +
    + +
    +

    Внешняя форма регистрации

    +

    Статус: {% if state.external_status == 'connected' %}Подключена{% elif state.external_status == 'requested' %}Запрос отправлен{% else %}Не подключена{% endif %}.

    + {% if state.external_url %}

    Адрес формы: {{ state.external_url }}

    {% endif %} + {% if connection_url %}

    Настроить подключение внешней формы

    {% endif %} +

    Для размещения ссылки на встроенную форму на лендинге подключение интеграции не требуется.

    + {% if state.external_status != 'connected' %} +
    + {% csrf_token %} + {{ connection.as_p }} + +
    + {% endif %} +
    + {% if preview is not None %} +
    +

    Предпросмотр анкеты участника

    +

    Это предпросмотр. Аккаунт и участие не создаются.

    + +
    + {% if form.cleaned_data.mode == 'external' %} +

    Участник перейдет на подключенную внешнюю форму.

    + {% else %} +

    Новый пользователь также заполнит данные аккаунта PROCOLLAB.

    + {{ preview.as_p }} + + {% endif %} +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/templates/partner_programs/admin/registration_connection.html b/templates/partner_programs/admin/registration_connection.html new file mode 100644 index 00000000..00c9a769 --- /dev/null +++ b/templates/partner_programs/admin/registration_connection.html @@ -0,0 +1,7 @@ +{% extends "admin/change_form.html" %} +{% block object-tools-items %} + {% if original %} +
  • Редактировать анкету регистрации
  • + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/templates/partner_programs/admin/registration_field.html b/templates/partner_programs/admin/registration_field.html new file mode 100644 index 00000000..39ec53cc --- /dev/null +++ b/templates/partner_programs/admin/registration_field.html @@ -0,0 +1,12 @@ +
    + Поле анкеты + {{ field_form.non_field_errors }} + {% for hidden in field_form.hidden_fields %}{{ hidden }}{{ hidden.errors }}{% endfor %} + {% for field in field_form.visible_fields %} +
    + {{ field.errors }} + {{ field.label_tag }} {{ field }} + {% if field.help_text %}

    {{ field.help_text }}

    {% endif %} +
    + {% endfor %} +
    From 22893d02307ebe1d26cc6ae5ef37d5bd4d15ef4b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 15:06:02 +0300 Subject: [PATCH 2/4] fix: serve registration editor assets through admin static route --- .../{ => admin}/partner_programs/admin_registration.css | 0 .../{ => admin}/partner_programs/admin_registration.js | 0 partner_programs/tests/test_registration_admin.py | 9 +++++++++ scripts/tests/test_dev_nginx_routes.py | 2 ++ templates/partner_programs/admin/registration.html | 4 ++-- 5 files changed, 13 insertions(+), 2 deletions(-) rename assets/{ => admin}/partner_programs/admin_registration.css (100%) rename assets/{ => admin}/partner_programs/admin_registration.js (100%) diff --git a/assets/partner_programs/admin_registration.css b/assets/admin/partner_programs/admin_registration.css similarity index 100% rename from assets/partner_programs/admin_registration.css rename to assets/admin/partner_programs/admin_registration.css diff --git a/assets/partner_programs/admin_registration.js b/assets/admin/partner_programs/admin_registration.js similarity index 100% rename from assets/partner_programs/admin_registration.js rename to assets/admin/partner_programs/admin_registration.js diff --git a/partner_programs/tests/test_registration_admin.py b/partner_programs/tests/test_registration_admin.py index f92dc82d..022a7e25 100644 --- a/partner_programs/tests/test_registration_admin.py +++ b/partner_programs/tests/test_registration_admin.py @@ -76,6 +76,15 @@ def test_program_card_links_to_russian_editor_without_creating_config(self): self.assertContains(page, label) self.assertFalse(ProgramRegistration.objects.exists()) + def test_editor_assets_use_admin_static_route_available_in_dev(self): + from django.contrib.staticfiles import finders + + page = self.client.get(self.url) + for filename in ("admin_registration.js", "admin_registration.css"): + path = f"admin/partner_programs/{filename}" + self.assertContains(page, f"/static/{path}") + self.assertIsNotNone(finders.find(path)) + def test_create_draft_then_publish_and_enroll_via_public_api(self): self.assertEqual(self.client.post(self.url, self.payload()).status_code, 302) config = ProgramRegistration.objects.get(program=self.program) diff --git a/scripts/tests/test_dev_nginx_routes.py b/scripts/tests/test_dev_nginx_routes.py index 6dc3f492..8e699140 100644 --- a/scripts/tests/test_dev_nginx_routes.py +++ b/scripts/tests/test_dev_nginx_routes.py @@ -109,6 +109,8 @@ def test_spa_reads_and_api_requests(self): "/auth/unknown/", "/auth/login/unrecognized/", "/programs/6/", "/solution-invitations/123/", "/solution-invitations/123/accept/", "/solution-form-templates/unknown/", + "/static/admin/partner_programs/admin_registration.js", + "/static/admin/partner_programs/admin_registration.css", ): status, content_type, body = request(base_url, path, context=context) self.assertEqual((status, content_type), (404, "application/json")) diff --git a/templates/partner_programs/admin/registration.html b/templates/partner_programs/admin/registration.html index 17742663..38e95c83 100644 --- a/templates/partner_programs/admin/registration.html +++ b/templates/partner_programs/admin/registration.html @@ -2,10 +2,10 @@ {% load static %} {% block extrastyle %}{{ block.super }} - + {% endblock %} {% block extrahead %}{{ block.super }} - + {% endblock %} {% block breadcrumbs %}