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
17 changes: 17 additions & 0 deletions docs/native-program-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Встроенная регистрация в программу

Новый контур включается организатором через «Настройки → Регистрация». Программы без опубликованной конфигурации сохраняют прежние ссылки, 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 автоматически.

Черновик: `{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 МБ, считает пустые строки и повторы. Пустой список допустим в черновике, но блокирует публикацию.

Публичный `registration/` GET доступен для опубликованной программы с опубликованным native-способом. Ответ включает поля, `version`, `closed`, `authenticated`, `enrolled` и путь программы. POST этого URL записывает только текущего подтверждённого пользователя: `version`, `answers`. Существующее участие возвращается идемпотентно, в том числе после дедлайна. POST `registration/signup/` принимает также имя, фамилию, email, дату рождения, пароль/повтор и согласия `accept_policy`, `accept_age`. Существующая почта требует входа; профиль и пароль существующего аккаунта не меняются. До подтверждения почты создаются только неактивный аккаунт и ожидающая анкета.

Письмо Celery ведёт на `/program-registration/{id}#confirmation=...`: доказательство почты находится во fragment, не в HTTP URL. POST `registration/confirm/` с `token` активирует аккаунт и возвращает JWT в теле ответа, без токенов в redirect URL. Доказательство действует 24 часа и используется один раз. POST `registration/resend/` принимает ограниченную квитанцию `receipt` либо доказательство из письма (до 7 дней); интервал повторной отправки — минимум 60 секунд. После закрытия регистрации подтверждается аккаунт, но новое участие не создаётся. Если анкета изменилась и требуется новый ответ, аккаунт подтверждается, а клиент получает `review_required` и сохранённые ответы для проверки текущей формы.

Новые участники сразу переходят в программу без выбора роли и вводного onboarding. Проверки доступа и глобальная модерация профиля сохраняются; `verification_date` не подставляется. Обычная регистрация и короткая регистрация приглашённых экспертов не изменяются. Восстановление пароля получает `registration_program_id` и возвращает к этой программе.

Ответы хранятся отдельно от профиля и полей проекта. Каждое участие содержит snapshot анкеты: удаление поля из следующих версий архивирует уже полученные ответы. Новые обязательные поля не проверяются повторно для уже зарегистрированных участников. Ответ региона из общего справочника доступен фильтрам участников без изменения глобального профиля. Ответы доступны в списке участников (`registration_answers`) и на листе «Анкеты» отфильтрованной XLSX-выгрузки. Текстовые ячейки экспортируются как строки, без выполнения формул.

Порядок DEV rollout: backend/migration/runtime-права/Celery → Angular. Откат UI не требует удаления таблиц или ответов. Production deployment в эту задачу не входит.
24 changes: 24 additions & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import re
from partner_programs.registration_models import ProgramRegistration

import tablib
from django import forms
Expand Down Expand Up @@ -357,3 +358,26 @@ def get_deleted_objects(self, objs, request):
)
list_filter = ("partner_program",)
search_fields = ("name", "label", "help_text")


@admin.register(ProgramRegistration)
class ProgramRegistrationAdmin(admin.ModelAdmin):
list_display = ("program", "external_status", "requested_at", "published_at")
list_filter = ("external_status",)
search_fields = ("program__name", "external_url")
readonly_fields = (
"program",
"draft",
"published",
"revision",
"published_version",
"published_at",
"requested_by",
"requested_at",
)

def has_add_permission(self, request):
return False

def has_delete_permission(self, request, obj=None):
return False
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Generated by Django 4.2.11 on 2026-10-19 22:57

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('partner_programs', '0023_organizerinvitation_and_more'),
]

operations = [
migrations.AddField(
model_name='partnerprogramuserprofile',
name='registration_region',
field=models.CharField(blank=True, default='', editable=False, max_length=255),
),
migrations.AddField(
model_name='partnerprogramuserprofile',
name='registration_schema',
field=models.JSONField(blank=True, default=dict, editable=False),
),
migrations.CreateModel(
name='ProgramRegistration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('draft', models.JSONField(default=dict)),
('published', models.JSONField(blank=True, editable=False, null=True)),
('revision', models.PositiveIntegerField(default=0, editable=False)),
('published_version', models.PositiveIntegerField(default=0, editable=False)),
('published_at', models.DateTimeField(editable=False, null=True)),
('external_status', models.CharField(choices=[('not_connected', 'not_connected'), ('requested', 'requested'), ('connected', 'connected')], default='not_connected', max_length=20)),
('external_url', models.URLField(blank=True)),
('connection_comment', models.TextField(blank=True)),
('requested_at', models.DateTimeField(blank=True, null=True)),
('program', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='registration_config', to='partner_programs.partnerprogram')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PendingProgramRegistration',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('answers', models.JSONField(default=dict)),
('schema', models.JSONField(default=dict)),
('generation', models.PositiveIntegerField(default=1)),
('confirmed_at', models.DateTimeField(editable=False, null=True)),
('last_sent_at', models.DateTimeField(auto_now_add=True)),
('delivery_status', models.CharField(default='queued', max_length=12)),
('created_at', models.DateTimeField(auto_now_add=True)),
('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='pendingprogramregistration',
constraint=models.UniqueConstraint(fields=('program', 'user'), name='unique_pending_program_registration'),
),
]
9 changes: 9 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from files.models import UserFile
from partner_programs.constants import get_default_data_schema
from projects.models import Project
from partner_programs.registration_models import ( # noqa: F401
ProgramRegistration,
PendingProgramRegistration,
)

User = get_user_model()

Expand Down Expand Up @@ -295,6 +299,11 @@ class PartnerProgramUserProfile(models.Model):
datetime_updated: A DateTimeField indicating date of update.
"""

registration_schema = models.JSONField(default=dict, blank=True, editable=False)
registration_region = models.CharField(
max_length=255, blank=True, default="", editable=False
)

user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
Expand Down
61 changes: 61 additions & 0 deletions partner_programs/registration_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Versioned participant questionnaire, separate from project fields and education."""

import uuid

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models


class ProgramRegistration(models.Model):
program = models.OneToOneField(
"partner_programs.PartnerProgram",
on_delete=models.CASCADE,
related_name="registration_config",
)
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")],
)
external_url = models.URLField(blank=True)
connection_comment = models.TextField(blank=True)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
)
requested_at = models.DateTimeField(null=True, blank=True)

def clean(self):
super().clean()
if self.external_status == "connected" and not self.external_url:
raise ValidationError({"external_url": "Укажите адрес подключённой формы."})

def __str__(self):
return f"Регистрация программы {self.program_id}"


class PendingProgramRegistration(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
program = models.ForeignKey(
"partner_programs.PartnerProgram", on_delete=models.CASCADE
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
answers = models.JSONField(default=dict)
schema = models.JSONField(default=dict)
generation = models.PositiveIntegerField(default=1)
confirmed_at = models.DateTimeField(null=True, editable=False)
last_sent_at = models.DateTimeField(auto_now_add=True)
delivery_status = models.CharField(max_length=12, default="queued")
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["program", "user"], name="unique_pending_program_registration"
)
]
92 changes: 92 additions & 0 deletions partner_programs/registration_regions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Canonical display values shared with Angular russian-regions-list.const.ts
REGIONS = [
"Республика Адыгея",
"Республика Алтай",
"Республика Башкортостан",
"Республика Бурятия",
"Республика Дагестан",
"Донецкая Народная Республика",
"Республика Ингушетия",
"Кабардино-Балкарская Республика",
"Республика Калмыкия",
"Карачаево-Черкесская Республика",
"Республика Карелия",
"Республика Коми",
"Республика Крым",
"Луганская Народная Республика",
"Республика Марий Эл",
"Республика Мордовия",
"Республика Саха (Якутия)",
"Республика Северная Осетия — Алания",
"Республика Татарстан",
"Республика Тыва",
"Удмуртская Республика",
"Республика Хакасия",
"Чеченская Республика",
"Чувашская Республика",
"Алтайский край",
"Забайкальский край",
"Камчатский край",
"Краснодарский край",
"Красноярский край",
"Пермский край",
"Приморский край",
"Ставропольский край",
"Хабаровский край",
"Амурская область",
"Архангельская область",
"Астраханская область",
"Белгородская область",
"Брянская область",
"Владимирская область",
"Волгоградская область",
"Вологодская область",
"Воронежская область",
"Запорожская область",
"Ивановская область",
"Иркутская область",
"Калининградская область",
"Калужская область",
"Кемеровская область — Кузбасс",
"Кировская область",
"Костромская область",
"Курганская область",
"Курская область",
"Ленинградская область",
"Липецкая область",
"Магаданская область",
"Московская область",
"Мурманская область",
"Нижегородская область",
"Новгородская область",
"Новосибирская область",
"Омская область",
"Оренбургская область",
"Орловская область",
"Пензенская область",
"Псковская область",
"Ростовская область",
"Рязанская область",
"Самарская область",
"Саратовская область",
"Сахалинская область",
"Свердловская область",
"Смоленская область",
"Тамбовская область",
"Тверская область",
"Томская область",
"Тульская область",
"Тюменская область",
"Ульяновская область",
"Херсонская область",
"Челябинская область",
"Ярославская область",
"Москва",
"Санкт-Петербург",
"Севастополь",
"Еврейская автономная область",
"Ненецкий автономный округ",
"Ханты-Мансийский автономный округ — Югра",
"Чукотский автономный округ",
"Ямало-Ненецкий автономный округ",
]
Loading
Loading