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
2 changes: 1 addition & 1 deletion docs/native-program-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 МБ, считает пустые строки и повторы. Пустой список допустим в черновике, но блокирует публикацию.

Expand Down
6 changes: 5 additions & 1 deletion partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -374,6 +376,8 @@ class ProgramRegistrationAdmin(admin.ModelAdmin):
"published_at",
"requested_by",
"requested_at",
"connection_website_url",
"connection_contact",
)

def has_add_permission(self, request):
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
]
4 changes: 4 additions & 0 deletions partner_programs/registration_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
10 changes: 8 additions & 2 deletions partner_programs/registration_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions partner_programs/services/native_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions partner_programs/tests/test_native_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand All @@ -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)
Expand Down
Loading