From f83638f618dc8678307f5f0ed62eba3f457a3a04 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 20 Oct 2026 11:48:36 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=D1=81=D0=B0=D0=B9=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=BF=D1=80=D0=B8=D1=8F=D1=82=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B5=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/native-program-registration.md | 2 +- partner_programs/admin.py | 7 +- .../0025_registration_connection_details.py | 31 +++++++ partner_programs/registration_models.py | 4 + partner_programs/registration_views.py | 10 +- .../services/native_registration.py | 2 + .../tests/test_native_registration.py | 92 +++++++++++++++++++ 7 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 partner_programs/migrations/0025_registration_connection_details.py diff --git a/docs/native-program-registration.md b/docs/native-program-registration.md index a65eb9ae..316a1f3d 100644 --- a/docs/native-program-registration.md +++ b/docs/native-program-registration.md @@ -2,7 +2,7 @@ Новый контур включается организатором через «Настройки → Регистрация». Программы без опубликованной конфигурации сохраняют прежние ссылки, JSON-анкету и интеграции. Новые таблицы и snapshot-поля добавляет миграция `partner_programs.0024`; существующие ответы не преобразуются. -Организатор программы (либо действующий администратор по `can_manage_program`) может читать/сохранять `management/registration/` (GET/PUT), публиковать `management/registration/publish/` (POST), предварительно разбирать `management/registration/import/` (POST multipart `file`) и сохранять запрос внешнего подключения `management/registration/connection/` (POST `url`, `comment`). Изменение анкеты требует текущую целочисленную `revision`; конфликт возвращает 409. Импорт не меняет анкету. Статус `connected` может установить только администратор в Django после фактической настройки интеграции; запрос организатора не подключает webhook автоматически. +Организатор программы (либо действующий администратор по `can_manage_program`) может читать/сохранять `management/registration/` (GET/PUT), публиковать `management/registration/publish/` (POST), предварительно разбирать `management/registration/import/` (POST multipart `file`) и сохранять запрос внешнего подключения `management/registration/connection/` (POST `url`, необязательные `comment` до 2000 символов и `contact` до 500). `url` — сайт мероприятия для обращения к администратору; он хранится отдельно в `connection_website_url`, а контакт — в `connection_contact`. Миграция 0025 сохраняет сайт ранее созданных запросов, не меняя `external_url`. Администратор видит запрос и контакт в Django; после настройки он указывает фактическую ссылку регистрации в `external_url`. Изменение анкеты требует текущую целочисленную `revision`; конфликт возвращает 409. Импорт не меняет анкету. Статус `connected` может установить только администратор в Django после фактической настройки интеграции; запрос организатора не подключает webhook автоматически. Черновик: `{mode: native|external, fields: [...]}`. У поля стабильный идентификатор `f` + 32 hex-символа, `label`, `type` (`text`, `textarea`, `number`, `date`, `select`, `multiselect`), `help_text`, `required`, `source` (`own`, `universities`, `regions`), `options`, `allow_other`. Порядок массива определяет порядок анкеты. Свой список — не более 5000 вариантов по 500 символов. Импорт принимает одноколоночный XLSX/CSV UTF-8 до 2 МБ, считает пустые строки и повторы. Пустой список допустим в черновике, но блокирует публикацию. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 44d035b8..11a9eaa7 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -364,7 +364,9 @@ def get_deleted_objects(self, objs, request): class ProgramRegistrationAdmin(admin.ModelAdmin): list_display = ("program", "external_status", "requested_at", "published_at") list_filter = ("external_status",) - search_fields = ("program__name", "external_url") + search_fields = ( + "program__name", "external_url", "connection_website_url", "connection_contact" + ) readonly_fields = ( "program", "draft", @@ -374,6 +376,9 @@ class ProgramRegistrationAdmin(admin.ModelAdmin): "published_at", "requested_by", "requested_at", + "connection_website_url", + "connection_comment", + "connection_contact", ) def has_add_permission(self, request): diff --git a/partner_programs/migrations/0025_registration_connection_details.py b/partner_programs/migrations/0025_registration_connection_details.py new file mode 100644 index 00000000..04c1d4a2 --- /dev/null +++ b/partner_programs/migrations/0025_registration_connection_details.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2026-10-20 08:45 + +from django.db import migrations, models + + +def preserve_requested_websites(apps, schema_editor): + Registration = apps.get_model('partner_programs', 'ProgramRegistration') + Registration.objects.using(schema_editor.connection.alias).filter( + external_status='requested', connection_website_url='' + ).exclude(external_url='').update(connection_website_url=models.F('external_url')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_programs', '0024_partnerprogramuserprofile_registration_schema_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='programregistration', + name='connection_contact', + field=models.CharField(blank=True, max_length=500, verbose_name='Контакт для связи'), + ), + migrations.AddField( + model_name='programregistration', + name='connection_website_url', + field=models.URLField(blank=True, verbose_name='Сайт мероприятия'), + ), + migrations.RunPython(preserve_requested_websites, migrations.RunPython.noop), + ] diff --git a/partner_programs/registration_models.py b/partner_programs/registration_models.py index 6401c253..456c29cd 100644 --- a/partner_programs/registration_models.py +++ b/partner_programs/registration_models.py @@ -24,7 +24,11 @@ class ProgramRegistration(models.Model): choices=[(v, v) for v in ("not_connected", "requested", "connected")], ) external_url = models.URLField(blank=True) + connection_website_url = models.URLField("Сайт мероприятия", 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 ) diff --git a/partner_programs/registration_views.py b/partner_programs/registration_views.py index aaad44e4..a864ac2f 100644 --- a/partner_programs/registration_views.py +++ b/partner_programs/registration_views.py @@ -123,7 +123,12 @@ def post(self, request, pk): class ConnectionWrite(RegistrationInput): url = HttpURLField(max_length=200) - comment = serializers.CharField(max_length=2000, allow_blank=True) + comment = serializers.CharField( + max_length=2000, allow_blank=True, required=False, default="" + ) + contact = serializers.CharField( + max_length=500, allow_blank=True, required=False, default="" + ) class RegistrationConnectionView(ManagementBase): @@ -133,8 +138,9 @@ def post(self, request, pk): program = managed_program(pk, request.user, lock=True) config = service.locked_config(program) if config.external_status != "connected": - config.external_url = data["url"] + 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" diff --git a/partner_programs/services/native_registration.py b/partner_programs/services/native_registration.py index a527f175..451a06f6 100644 --- a/partner_programs/services/native_registration.py +++ b/partner_programs/services/native_registration.py @@ -147,6 +147,8 @@ def settings_data(program): if config else program.registration_link or "", "connection_comment": config.connection_comment if config else "", + "connection_website_url": config.connection_website_url if config else "", + "connection_contact": config.connection_contact if config else "", "answered_field_ids": list( { k diff --git a/partner_programs/tests/test_native_registration.py b/partner_programs/tests/test_native_registration.py index d368cc00..0d858c8f 100644 --- a/partner_programs/tests/test_native_registration.py +++ b/partner_programs/tests/test_native_registration.py @@ -360,6 +360,11 @@ def test_external_connection_request_cannot_self_approve(self): ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["external_status"], "requested") + self.config.refresh_from_db() + self.assertEqual( + self.config.connection_website_url, "https://example.test/registration" + ) + self.assertEqual(self.config.external_url, "") self.assertEqual( self.post( "/management/registration/publish/", @@ -379,6 +384,93 @@ def test_external_connection_request_cannot_self_approve(self): 400, ) + def test_external_request_optional_comment_and_contact_are_saved_for_admin(self): + self.client.force_authenticate(self.manager) + response = self.post( + "/management/registration/connection/", {"url": "https://event.example.test"} + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["connection_comment"], "") + self.assertEqual(response.data["connection_contact"], "") + response = self.post( + "/management/registration/connection/", + { + "url": "https://event.example.test", + "comment": "Нужна регистрация на чемпионат", + "contact": "@event_organizer", + }, + ) + self.assertEqual(response.status_code, 200, response.data) + self.config.refresh_from_db() + self.assertEqual(self.config.connection_website_url, "https://event.example.test") + self.assertEqual(self.config.connection_contact, "@event_organizer") + self.assertEqual(self.config.connection_comment, "Нужна регистрация на чемпионат") + self.assertEqual(self.config.external_url, "") + self.assertEqual(response.data["connection_contact"], "@event_organizer") + self.assertEqual(response.data["connection_website_url"], "https://event.example.test") + self.assertEqual(self.config.requested_by, self.manager) + + def test_external_request_invalid_details_do_not_write(self): + self.client.force_authenticate(self.manager) + for data in ( + {"url": "not-a-website"}, + {"url": "https://example.test", "contact": "x" * 501}, + {"url": "https://example.test", "comment": "x" * 2001}, + ): + with self.subTest(data=list(data)): + response = self.post("/management/registration/connection/", data) + self.assertEqual(response.status_code, 400, response.data) + self.config.refresh_from_db() + self.assertEqual(self.config.revision, 0) + self.assertEqual(self.config.connection_website_url, "") + self.assertEqual(self.config.connection_contact, "") + + def test_connection_details_migration_preserves_old_request_and_form_url(self): + import importlib + from django.apps import apps + from django.db import connection + from types import SimpleNamespace + + self.config.external_status = "requested" + self.config.external_url = "https://previous.example.test/form" + self.config.save() + migration = importlib.import_module( + "partner_programs.migrations.0025_registration_connection_details" + ) + migration.preserve_requested_websites( + apps, SimpleNamespace(connection=connection) + ) + self.config.refresh_from_db() + self.assertEqual( + self.config.connection_website_url, "https://previous.example.test/form" + ) + self.assertEqual(self.config.external_url, "https://previous.example.test/form") + self.config.connection_website_url = "https://event.example.test" + self.config.save() + migration.preserve_requested_websites( + apps, SimpleNamespace(connection=connection) + ) + self.config.refresh_from_db() + self.assertEqual(self.config.connection_website_url, "https://event.example.test") + + def test_external_request_requires_manager_and_does_not_change_connected_form(self): + data = {"url": "https://event.example.test", "contact": "@event_organizer"} + self.client.force_authenticate(self.participant) + self.assertEqual( + self.post("/management/registration/connection/", data).status_code, 403 + ) + self.config.external_status = "connected" + self.config.external_url = "https://forms.example.test/register" + self.config.save() + self.client.force_authenticate(self.manager) + response = self.post("/management/registration/connection/", data) + self.assertEqual(response.status_code, 200, response.data) + self.config.refresh_from_db() + self.assertEqual(self.config.external_status, "connected") + self.assertEqual(self.config.external_url, "https://forms.example.test/register") + self.assertEqual(self.config.connection_website_url, "") + self.assertEqual(self.config.connection_contact, "") + def test_legacy_registration_cannot_bypass_published_native_schema(self): self.client.force_authenticate(self.participant) self.assertEqual(self.post("/register/", {}).status_code, 400) From 3c73db9cb13dcb740e8696a697207352130f495f Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 20 Oct 2026 11:49:27 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 11a9eaa7..66639488 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -377,7 +377,6 @@ class ProgramRegistrationAdmin(admin.ModelAdmin): "requested_by", "requested_at", "connection_website_url", - "connection_comment", "connection_contact", )