Skip to content

Commit ee2f151

Browse files
committed
feat: встроенная регистрация участников в программу
1 parent e8161e5 commit ee2f151

19 files changed

Lines changed: 1730 additions & 9 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Встроенная регистрация в программу
2+
3+
Новый контур включается организатором через «Настройки → Регистрация». Программы без опубликованной конфигурации сохраняют прежние ссылки, JSON-анкету и интеграции. Новые таблицы и snapshot-поля добавляет миграция `partner_programs.0024`; существующие ответы не преобразуются.
4+
5+
Организатор программы (либо действующий администратор по `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 автоматически.
6+
7+
Черновик: `{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 МБ, считает пустые строки и повторы. Пустой список допустим в черновике, но блокирует публикацию.
8+
9+
Публичный `registration/` GET доступен для опубликованной программы с опубликованным native-способом. Ответ включает поля, `version`, `closed`, `authenticated`, `enrolled` и путь программы. POST этого URL записывает только текущего подтверждённого пользователя: `version`, `answers`. Существующее участие возвращается идемпотентно, в том числе после дедлайна. POST `registration/signup/` принимает также имя, фамилию, email, дату рождения, пароль/повтор и согласия `accept_policy`, `accept_age`. Существующая почта требует входа; профиль и пароль существующего аккаунта не меняются. До подтверждения почты создаются только неактивный аккаунт и ожидающая анкета.
10+
11+
Письмо Celery ведёт на `/program-registration/{id}#confirmation=...`: доказательство почты находится во fragment, не в HTTP URL. POST `registration/confirm/` с `token` активирует аккаунт и возвращает JWT в теле ответа, без токенов в redirect URL. Доказательство действует 24 часа и используется один раз. POST `registration/resend/` принимает ограниченную квитанцию `receipt` либо доказательство из письма (до 7 дней); интервал повторной отправки — минимум 60 секунд. После закрытия регистрации подтверждается аккаунт, но новое участие не создаётся. Если анкета изменилась и требуется новый ответ, аккаунт подтверждается, а клиент получает `review_required` и сохранённые ответы для проверки текущей формы.
12+
13+
Новые участники сразу переходят в программу без выбора роли и вводного onboarding. Проверки доступа и глобальная модерация профиля сохраняются; `verification_date` не подставляется. Обычная регистрация и короткая регистрация приглашённых экспертов не изменяются. Восстановление пароля получает `registration_program_id` и возвращает к этой программе.
14+
15+
Ответы хранятся отдельно от профиля и полей проекта. Каждое участие содержит snapshot анкеты: удаление поля из следующих версий архивирует уже полученные ответы. Новые обязательные поля не проверяются повторно для уже зарегистрированных участников. Ответ региона из общего справочника доступен фильтрам участников без изменения глобального профиля. Ответы доступны в списке участников (`registration_answers`) и на листе «Анкеты» отфильтрованной XLSX-выгрузки. Текстовые ячейки экспортируются как строки, без выполнения формул.
16+
17+
Порядок DEV rollout: backend/migration/runtime-права/Celery → Angular. Откат UI не требует удаления таблиц или ответов. Production deployment в эту задачу не входит.

‎partner_programs/admin.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from partner_programs.registration_models import ProgramRegistration
23

34
import tablib
45
from django import forms
@@ -357,3 +358,26 @@ def get_deleted_objects(self, objs, request):
357358
)
358359
list_filter = ("partner_program",)
359360
search_fields = ("name", "label", "help_text")
361+
362+
363+
@admin.register(ProgramRegistration)
364+
class ProgramRegistrationAdmin(admin.ModelAdmin):
365+
list_display = ("program", "external_status", "requested_at", "published_at")
366+
list_filter = ("external_status",)
367+
search_fields = ("program__name", "external_url")
368+
readonly_fields = (
369+
"program",
370+
"draft",
371+
"published",
372+
"revision",
373+
"published_version",
374+
"published_at",
375+
"requested_by",
376+
"requested_at",
377+
)
378+
379+
def has_add_permission(self, request):
380+
return False
381+
382+
def has_delete_permission(self, request, obj=None):
383+
return False
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Generated by Django 4.2.11 on 2026-10-19 22:57
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import uuid
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
('partner_programs', '0023_organizerinvitation_and_more'),
14+
]
15+
16+
operations = [
17+
migrations.AddField(
18+
model_name='partnerprogramuserprofile',
19+
name='registration_region',
20+
field=models.CharField(blank=True, default='', editable=False, max_length=255),
21+
),
22+
migrations.AddField(
23+
model_name='partnerprogramuserprofile',
24+
name='registration_schema',
25+
field=models.JSONField(blank=True, default=dict, editable=False),
26+
),
27+
migrations.CreateModel(
28+
name='ProgramRegistration',
29+
fields=[
30+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
31+
('draft', models.JSONField(default=dict)),
32+
('published', models.JSONField(blank=True, editable=False, null=True)),
33+
('revision', models.PositiveIntegerField(default=0, editable=False)),
34+
('published_version', models.PositiveIntegerField(default=0, editable=False)),
35+
('published_at', models.DateTimeField(editable=False, null=True)),
36+
('external_status', models.CharField(choices=[('not_connected', 'not_connected'), ('requested', 'requested'), ('connected', 'connected')], default='not_connected', max_length=20)),
37+
('external_url', models.URLField(blank=True)),
38+
('connection_comment', models.TextField(blank=True)),
39+
('requested_at', models.DateTimeField(blank=True, null=True)),
40+
('program', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='registration_config', to='partner_programs.partnerprogram')),
41+
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
42+
],
43+
),
44+
migrations.CreateModel(
45+
name='PendingProgramRegistration',
46+
fields=[
47+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
48+
('answers', models.JSONField(default=dict)),
49+
('schema', models.JSONField(default=dict)),
50+
('generation', models.PositiveIntegerField(default=1)),
51+
('confirmed_at', models.DateTimeField(editable=False, null=True)),
52+
('last_sent_at', models.DateTimeField(auto_now_add=True)),
53+
('delivery_status', models.CharField(default='queued', max_length=12)),
54+
('created_at', models.DateTimeField(auto_now_add=True)),
55+
('program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partner_programs.partnerprogram')),
56+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
57+
],
58+
),
59+
migrations.AddConstraint(
60+
model_name='pendingprogramregistration',
61+
constraint=models.UniqueConstraint(fields=('program', 'user'), name='unique_pending_program_registration'),
62+
),
63+
]

‎partner_programs/models.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
from files.models import UserFile
77
from partner_programs.constants import get_default_data_schema
88
from projects.models import Project
9+
from partner_programs.registration_models import ( # noqa: F401
10+
ProgramRegistration,
11+
PendingProgramRegistration,
12+
)
913

1014
User = get_user_model()
1115

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

302+
registration_schema = models.JSONField(default=dict, blank=True, editable=False)
303+
registration_region = models.CharField(
304+
max_length=255, blank=True, default="", editable=False
305+
)
306+
298307
user = models.ForeignKey(
299308
User,
300309
on_delete=models.SET_NULL,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Versioned participant questionnaire, separate from project fields and education."""
2+
3+
import uuid
4+
5+
from django.conf import settings
6+
from django.core.exceptions import ValidationError
7+
from django.db import models
8+
9+
10+
class ProgramRegistration(models.Model):
11+
program = models.OneToOneField(
12+
"partner_programs.PartnerProgram",
13+
on_delete=models.CASCADE,
14+
related_name="registration_config",
15+
)
16+
draft = models.JSONField(default=dict)
17+
published = models.JSONField(null=True, blank=True, editable=False)
18+
revision = models.PositiveIntegerField(default=0, editable=False)
19+
published_version = models.PositiveIntegerField(default=0, editable=False)
20+
published_at = models.DateTimeField(null=True, editable=False)
21+
external_status = models.CharField(
22+
max_length=20,
23+
default="not_connected",
24+
choices=[(v, v) for v in ("not_connected", "requested", "connected")],
25+
)
26+
external_url = models.URLField(blank=True)
27+
connection_comment = models.TextField(blank=True)
28+
requested_by = models.ForeignKey(
29+
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
30+
)
31+
requested_at = models.DateTimeField(null=True, blank=True)
32+
33+
def clean(self):
34+
super().clean()
35+
if self.external_status == "connected" and not self.external_url:
36+
raise ValidationError({"external_url": "Укажите адрес подключённой формы."})
37+
38+
def __str__(self):
39+
return f"Регистрация программы {self.program_id}"
40+
41+
42+
class PendingProgramRegistration(models.Model):
43+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
44+
program = models.ForeignKey(
45+
"partner_programs.PartnerProgram", on_delete=models.CASCADE
46+
)
47+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
48+
answers = models.JSONField(default=dict)
49+
schema = models.JSONField(default=dict)
50+
generation = models.PositiveIntegerField(default=1)
51+
confirmed_at = models.DateTimeField(null=True, editable=False)
52+
last_sent_at = models.DateTimeField(auto_now_add=True)
53+
delivery_status = models.CharField(max_length=12, default="queued")
54+
created_at = models.DateTimeField(auto_now_add=True)
55+
56+
class Meta:
57+
constraints = [
58+
models.UniqueConstraint(
59+
fields=["program", "user"], name="unique_pending_program_registration"
60+
)
61+
]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Canonical display values shared with Angular russian-regions-list.const.ts
2+
REGIONS = [
3+
"Республика Адыгея",
4+
"Республика Алтай",
5+
"Республика Башкортостан",
6+
"Республика Бурятия",
7+
"Республика Дагестан",
8+
"Донецкая Народная Республика",
9+
"Республика Ингушетия",
10+
"Кабардино-Балкарская Республика",
11+
"Республика Калмыкия",
12+
"Карачаево-Черкесская Республика",
13+
"Республика Карелия",
14+
"Республика Коми",
15+
"Республика Крым",
16+
"Луганская Народная Республика",
17+
"Республика Марий Эл",
18+
"Республика Мордовия",
19+
"Республика Саха (Якутия)",
20+
"Республика Северная Осетия — Алания",
21+
"Республика Татарстан",
22+
"Республика Тыва",
23+
"Удмуртская Республика",
24+
"Республика Хакасия",
25+
"Чеченская Республика",
26+
"Чувашская Республика",
27+
"Алтайский край",
28+
"Забайкальский край",
29+
"Камчатский край",
30+
"Краснодарский край",
31+
"Красноярский край",
32+
"Пермский край",
33+
"Приморский край",
34+
"Ставропольский край",
35+
"Хабаровский край",
36+
"Амурская область",
37+
"Архангельская область",
38+
"Астраханская область",
39+
"Белгородская область",
40+
"Брянская область",
41+
"Владимирская область",
42+
"Волгоградская область",
43+
"Вологодская область",
44+
"Воронежская область",
45+
"Запорожская область",
46+
"Ивановская область",
47+
"Иркутская область",
48+
"Калининградская область",
49+
"Калужская область",
50+
"Кемеровская область — Кузбасс",
51+
"Кировская область",
52+
"Костромская область",
53+
"Курганская область",
54+
"Курская область",
55+
"Ленинградская область",
56+
"Липецкая область",
57+
"Магаданская область",
58+
"Московская область",
59+
"Мурманская область",
60+
"Нижегородская область",
61+
"Новгородская область",
62+
"Новосибирская область",
63+
"Омская область",
64+
"Оренбургская область",
65+
"Орловская область",
66+
"Пензенская область",
67+
"Псковская область",
68+
"Ростовская область",
69+
"Рязанская область",
70+
"Самарская область",
71+
"Саратовская область",
72+
"Сахалинская область",
73+
"Свердловская область",
74+
"Смоленская область",
75+
"Тамбовская область",
76+
"Тверская область",
77+
"Томская область",
78+
"Тульская область",
79+
"Тюменская область",
80+
"Ульяновская область",
81+
"Херсонская область",
82+
"Челябинская область",
83+
"Ярославская область",
84+
"Москва",
85+
"Санкт-Петербург",
86+
"Севастополь",
87+
"Еврейская автономная область",
88+
"Ненецкий автономный округ",
89+
"Ханты-Мансийский автономный округ — Югра",
90+
"Чукотский автономный округ",
91+
"Ямало-Ненецкий автономный округ",
92+
]

0 commit comments

Comments
 (0)