diff --git a/docs/native-program-registration.md b/docs/native-program-registration.md new file mode 100644 index 00000000..a65eb9ae --- /dev/null +++ b/docs/native-program-registration.md @@ -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 в эту задачу не входит. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index cf159bd3..44d035b8 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -1,4 +1,5 @@ import re +from partner_programs.registration_models import ProgramRegistration import tablib from django import forms @@ -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 diff --git a/partner_programs/migrations/0024_partnerprogramuserprofile_registration_schema_and_more.py b/partner_programs/migrations/0024_partnerprogramuserprofile_registration_schema_and_more.py new file mode 100644 index 00000000..d01141d2 --- /dev/null +++ b/partner_programs/migrations/0024_partnerprogramuserprofile_registration_schema_and_more.py @@ -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'), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 7c364c38..0f37a666 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -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() @@ -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, diff --git a/partner_programs/registration_models.py b/partner_programs/registration_models.py new file mode 100644 index 00000000..6401c253 --- /dev/null +++ b/partner_programs/registration_models.py @@ -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" + ) + ] diff --git a/partner_programs/registration_regions.py b/partner_programs/registration_regions.py new file mode 100644 index 00000000..4ce4bf16 --- /dev/null +++ b/partner_programs/registration_regions.py @@ -0,0 +1,92 @@ +# Canonical display values shared with Angular russian-regions-list.const.ts +REGIONS = [ + "Республика Адыгея", + "Республика Алтай", + "Республика Башкортостан", + "Республика Бурятия", + "Республика Дагестан", + "Донецкая Народная Республика", + "Республика Ингушетия", + "Кабардино-Балкарская Республика", + "Республика Калмыкия", + "Карачаево-Черкесская Республика", + "Республика Карелия", + "Республика Коми", + "Республика Крым", + "Луганская Народная Республика", + "Республика Марий Эл", + "Республика Мордовия", + "Республика Саха (Якутия)", + "Республика Северная Осетия — Алания", + "Республика Татарстан", + "Республика Тыва", + "Удмуртская Республика", + "Республика Хакасия", + "Чеченская Республика", + "Чувашская Республика", + "Алтайский край", + "Забайкальский край", + "Камчатский край", + "Краснодарский край", + "Красноярский край", + "Пермский край", + "Приморский край", + "Ставропольский край", + "Хабаровский край", + "Амурская область", + "Архангельская область", + "Астраханская область", + "Белгородская область", + "Брянская область", + "Владимирская область", + "Волгоградская область", + "Вологодская область", + "Воронежская область", + "Запорожская область", + "Ивановская область", + "Иркутская область", + "Калининградская область", + "Калужская область", + "Кемеровская область — Кузбасс", + "Кировская область", + "Костромская область", + "Курганская область", + "Курская область", + "Ленинградская область", + "Липецкая область", + "Магаданская область", + "Московская область", + "Мурманская область", + "Нижегородская область", + "Новгородская область", + "Новосибирская область", + "Омская область", + "Оренбургская область", + "Орловская область", + "Пензенская область", + "Псковская область", + "Ростовская область", + "Рязанская область", + "Самарская область", + "Саратовская область", + "Сахалинская область", + "Свердловская область", + "Смоленская область", + "Тамбовская область", + "Тверская область", + "Томская область", + "Тульская область", + "Тюменская область", + "Ульяновская область", + "Херсонская область", + "Челябинская область", + "Ярославская область", + "Москва", + "Санкт-Петербург", + "Севастополь", + "Еврейская автономная область", + "Ненецкий автономный округ", + "Ханты-Мансийский автономный округ — Югра", + "Чукотский автономный округ", + "Ямало-Ненецкий автономный округ", +] diff --git a/partner_programs/registration_views.py b/partner_programs/registration_views.py new file mode 100644 index 00000000..aaad44e4 --- /dev/null +++ b/partner_programs/registration_views.py @@ -0,0 +1,189 @@ +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 +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed +from collections.abc import Mapping + +from partner_programs.management_views import ManagementBase +from partner_programs.serializers.management import StrictFieldsMixin, HttpURLField +from partner_programs.services.management import managed_program +from partner_programs.services import native_registration as service +from project_rates.workspace_views import PrivateView +from users.models import CustomUser + + +class RegistrationInput(StrictFieldsMixin, serializers.Serializer): + def to_internal_value(self, data): + if not isinstance(data, Mapping): + raise serializers.ValidationError( + {"non_field_errors": ["Отправьте объект с полями формы."]} + ) + return super().to_internal_value(data) + + +class RegistrationWrite(RegistrationInput): + revision = serializers.IntegerField(min_value=0) + draft = serializers.JSONField() + + +class EnrollmentWrite(RegistrationInput): + version = serializers.IntegerField(min_value=1) + answers = serializers.JSONField() + + +class SignupWrite(EnrollmentWrite): + first_name = serializers.CharField( + max_length=255, validators=CustomUser._meta.get_field("first_name").validators + ) + last_name = serializers.CharField( + max_length=255, validators=CustomUser._meta.get_field("last_name").validators + ) + birthday = serializers.DateField( + validators=CustomUser._meta.get_field("birthday").validators + ) + email = serializers.EmailField(max_length=254) + password = serializers.CharField(min_length=8, max_length=128, trim_whitespace=False) + repeat_password = serializers.CharField(max_length=128, trim_whitespace=False) + accept_policy = serializers.BooleanField() + accept_age = serializers.BooleanField() + + def validate(self, data): + if not data["accept_policy"]: + raise serializers.ValidationError( + {"accept_policy": "Подтвердите согласие с условиями платформы."} + ) + if ( + not data["accept_age"] + or (timezone.now().date() - data["birthday"]).days < 14 * 365 + ): + raise serializers.ValidationError( + {"birthday": "Для регистрации участнику должно быть не менее 14 лет."} + ) + if data["password"] != data["repeat_password"]: + raise serializers.ValidationError({"repeat_password": "Пароли не совпадают."}) + try: + validate_password( + data["password"], + CustomUser( + email=data["email"], + first_name=data["first_name"], + last_name=data["last_name"], + ), + ) + except DjangoValidationError as error: + raise serializers.ValidationError({"password": error.messages}) + return data + + +class TokenWrite(RegistrationInput): + token = serializers.CharField(max_length=2000) + + +def validated(cls, data): + form = cls(data=data) + form.is_valid(raise_exception=True) + return form.validated_data + + +class RegistrationSettingsView(ManagementBase): + def get(self, request, pk): + return Response(service.settings_data(managed_program(pk, request.user))) + + def put(self, request, pk): + return Response( + service.save_settings( + pk, request.user, validated(RegistrationWrite, request.data) + ) + ) + + +class RegistrationPublishView(ManagementBase): + def post(self, request, pk): + return Response( + service.save_settings( + pk, request.user, validated(RegistrationWrite, request.data), publish=True + ) + ) + + +class RegistrationImportView(ManagementBase): + def post(self, request, pk): + managed_program(pk, request.user) + upload = request.FILES.get("file") + if not upload: + raise serializers.ValidationError({"file": "Выберите XLSX/CSV файл."}) + return Response(service.import_options(upload)) + + +class ConnectionWrite(RegistrationInput): + url = HttpURLField(max_length=200) + comment = serializers.CharField(max_length=2000, allow_blank=True) + + +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": + config.external_url = data["url"] + config.connection_comment = data["comment"] + config.requested_by = request.user + config.requested_at = timezone.now() + config.external_status = "requested" + config.revision += 1 + config.save() + return Response(service.settings_data(program)) + + +class RegistrationThrottle(AnonRateThrottle): + rate = "12/min" + scope = "program_registration" + + +class OptionalRegistrationAuthentication(JWTAuthentication): + def authenticate(self, request): + try: + return super().authenticate(request) + except (InvalidToken, AuthenticationFailed): + return None + + +class PublicRegistrationView(PrivateView): + permission_classes = [AllowAny] + authentication_classes = [OptionalRegistrationAuthentication] + throttle_classes = [RegistrationThrottle] + + def get(self, request, pk): + return Response(service.public_data(service.open_program(pk), request.user)) + + def post(self, request, pk): + return Response( + service.enroll(pk, request.user, validated(EnrollmentWrite, request.data)) + ) + + +class PublicRegistrationActionView(PublicRegistrationView): + def get(self, request, pk, action): + return Response(status=405) + + def post(self, request, pk, action): + if action == "signup": + if request.user.is_authenticated: + raise serializers.ValidationError( + "Для своего аккаунта отправьте только анкету." + ) + return Response( + service.signup(pk, validated(SignupWrite, request.data)), status=201 + ) + if action in ("confirm", "resend"): + data = validated(TokenWrite, request.data) + return Response(getattr(service, action)(pk, data["token"])) + return Response(status=404) diff --git a/partner_programs/serializers/programs.py b/partner_programs/serializers/programs.py index f989ecaa..78bfbf8d 100644 --- a/partner_programs/serializers/programs.py +++ b/partner_programs/serializers/programs.py @@ -28,6 +28,20 @@ class PartnerProgramListSerializer(serializers.ModelSerializer): ) is_user_liked = serializers.SerializerMethodField(method_name="get_is_user_liked") is_user_member = serializers.SerializerMethodField(method_name="get_is_user_member") + registration_managed = serializers.SerializerMethodField() + + def get_registration_managed(self, program): + from partner_programs.services.native_registration import config_for + + config = config_for(program) + return bool(config and config.published) + + def to_representation(self, program): + from partner_programs.services.native_registration import public_link + + data = super().to_representation(program) + data["registration_link"] = public_link(program) + return data def _get_user(self): user = self.context.get("user") @@ -73,6 +87,7 @@ class Meta: "image_framing", "short_description", "registration_link", + "registration_managed", "datetime_registration_ends", "datetime_project_submission_ends", "datetime_evaluation_ends", @@ -99,6 +114,20 @@ class PartnerProgramBaseSerializerMixin(serializers.ModelSerializer): is_user_expert = serializers.SerializerMethodField() pending_expert_invitation_id = serializers.SerializerMethodField() courses = serializers.SerializerMethodField() + registration_managed = serializers.SerializerMethodField() + + def get_registration_managed(self, program): + from partner_programs.services.native_registration import config_for + + config = config_for(program) + return bool(config and config.published) + + def to_representation(self, program): + from partner_programs.services.native_registration import public_link + + data = super().to_representation(program) + data["registration_link"] = public_link(program) + return data def get_materials(self, program: PartnerProgram): materials = program.materials.all() @@ -210,6 +239,7 @@ class Meta: "image_framing", "presentation_address", "registration_link", + "registration_managed", "views_count", "datetime_registration_ends", "datetime_project_submission_ends", @@ -243,6 +273,7 @@ class Meta: "advertisement_image_address", "presentation_address", "registration_link", + "registration_managed", "datetime_registration_ends", "datetime_project_submission_ends", "datetime_evaluation_ends", diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 0aa22517..1226a622 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -86,6 +86,7 @@ def participant_rows(program, search="", query=None): # The profile's region picker writes to the historical `city` field. .annotate( participant_region=Coalesce( + NullIf(Trim("registration_region"), Value("")), NullIf(Trim("user__city"), Value("")), NullIf(Trim("user__region"), Value("")), Value(""), @@ -133,6 +134,13 @@ def participant_data(profile): "region": profile.participant_region or None, "registered_at": profile.datetime_created, "last_activity": profile.user.last_activity, + "registration_answers": [ + { + "label": field["label"], + "value": profile.partner_program_data.get(field["id"]), + } + for field in profile.registration_schema.get("fields", []) + ], } diff --git a/partner_programs/services/native_registration.py b/partner_programs/services/native_registration.py new file mode 100644 index 00000000..a527f175 --- /dev/null +++ b/partner_programs/services/native_registration.py @@ -0,0 +1,560 @@ +"""Published native enrollment. Legacy Tilda handlers remain independent.""" + +import copy +import csv +import io +import math +import re +from datetime import date, timedelta +from zipfile import ZipFile, BadZipFile + +from django.conf import settings +from django.core import signing +from django.db import transaction, IntegrityError +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework.exceptions import ValidationError, PermissionDenied +from rest_framework_simplejwt.tokens import RefreshToken + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramUserProfile, + ProgramRegistration, + PendingProgramRegistration, +) +from partner_programs.services.management import managed_program +from project_rates.workspace import WorkspaceConflict +from users.models import CustomUser, University + +SALT = "program-native-confirm-v1" +RECEIPT_SALT = "program-native-resend-v1" +FIELD_TYPES = ("text", "textarea", "number", "date", "select", "multiselect") +SOURCES = ("own", "universities", "regions") + + +def frontend_url(): + return settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + +def config_for(program): + if not hasattr(program, "_native_registration_config"): + try: + program._native_registration_config = program.registration_config + except ProgramRegistration.DoesNotExist: + program._native_registration_config = None + return program._native_registration_config + + +def public_link(program): + config = config_for(program) + if config and config.published: + if config.published["mode"] == "native": + return f"{frontend_url()}/program-registration/{program.pk}" + return config.external_url if config.external_status == "connected" else None + return program.registration_link + + +def initial_draft(program): + # Do not migrate legacy JSON answers or infer field types from old arbitrary data. + return {"mode": "external" if program.registration_link else "native", "fields": []} + + +def clean_schema(data, *, publishing=False): + if not isinstance(data, dict) or set(data) != {"mode", "fields"}: + raise ValidationError("Укажите способ регистрации и поля анкеты.") + if data["mode"] not in ("native", "external"): + raise ValidationError("Неизвестный способ регистрации.") + fields = data["fields"] + if not isinstance(fields, list) or len(fields) > 100: + raise ValidationError("В анкете может быть не более 100 полей.") + result, seen = [], set() + for field in fields: + if not isinstance(field, dict) or set(field) != { + "id", + "label", + "type", + "help_text", + "required", + "source", + "options", + "allow_other", + }: + raise ValidationError("Неверная конфигурация поля анкеты.") + key = field["id"] + if ( + not isinstance(key, str) + or not re.fullmatch(r"f[a-f0-9]{32}", key) + or key in seen + ): + raise ValidationError("У поля неверный или повторяющийся идентификатор.") + seen.add(key) + label, help_text = field["label"], field["help_text"] + if not isinstance(label, str) or not label.strip() or len(label) > 255: + raise ValidationError("Укажите название поля (до 255 символов).") + if not isinstance(help_text, str) or len(help_text) > 1000: + raise ValidationError("Подсказка: не более 1000 символов.") + if field["type"] not in FIELD_TYPES or field["source"] not in SOURCES: + raise ValidationError("Неизвестный тип поля или источник списка.") + if not isinstance(field["required"], bool) or not isinstance( + field["allow_other"], bool + ): + raise ValidationError("Некорректные настройки обязательности поля.") + options = field["options"] + if ( + not isinstance(options, list) + or len(options) > 5000 + or any( + not isinstance(v, str) or not v.strip() or len(v) > 500 for v in options + ) + ): + raise ValidationError( + "В списке должно быть не более 5000 строк по 500 символов." + ) + options = list(dict.fromkeys(v.strip() for v in options)) + if field["type"] not in ("select", "multiselect"): + if field["source"] != "own" or options or field["allow_other"]: + raise ValidationError( + "Список вариантов доступен только для выбора ответа." + ) + elif publishing and field["source"] == "own" and not options: + raise ValidationError(f"Добавьте варианты ответа для поля «{label}».") + if field["source"] != "own" and options: + raise ValidationError("Для справочника используется общий список платформы.") + result.append( + { + **field, + "label": label.strip(), + "help_text": help_text.strip(), + "options": options, + } + ) + return {"mode": data["mode"], "fields": result} + + +def settings_data(program): + config = config_for(program) + return { + "draft": config.draft if config else initial_draft(program), + "published": config.published if config else None, + "revision": config.revision if config else 0, + "published_version": config.published_version if config else 0, + "published_at": config.published_at if config else None, + "link": public_link(program) if config and config.published else None, + "external_status": config.external_status + if config + else ("connected" if program.registration_link else "not_connected"), + "external_url": config.external_url + if config + else program.registration_link or "", + "connection_comment": config.connection_comment if config else "", + "answered_field_ids": list( + { + k + for a in program.partner_program_profiles.values_list( + "partner_program_data", flat=True + ) + if isinstance(a, dict) + for k in a + } + ), + } + + +def locked_config(program): + return ProgramRegistration.objects.get_or_create( + program=program, + defaults={ + "draft": initial_draft(program), + "external_status": "connected" + if program.registration_link + else "not_connected", + "external_url": program.registration_link or "", + }, + )[0] + + +@transaction.atomic +def save_settings(pk, user, data, *, publish=False): + program = managed_program(pk, user, lock=True) + config = locked_config(program) + if data["revision"] != config.revision: + raise WorkspaceConflict( + "stale_registration", "Анкета изменена другим организатором. Обновите данные." + ) + schema = clean_schema(data["draft"], publishing=publish) + # Historical snapshot lives on each enrollment; removing an answered field archives it. + config.draft = schema + config.revision += 1 + if publish: + if program.draft: + raise ValidationError("Сначала опубликуйте саму программу в Django admin.") + if schema["mode"] == "external" and config.external_status != "connected": + raise ValidationError("Дождитесь подключения внешней формы администратором.") + config.published = copy.deepcopy(schema) + config.published_at = timezone.now() + config.published_version += 1 + config.save() + 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) + + +def published_config(program): + config = config_for(program) + if not config or not config.published or config.published["mode"] != "native": + raise ValidationError("Встроенная регистрация ещё не опубликована.") + return config + + +def check_open(program): + if program.datetime_registration_ends < timezone.now(): + raise WorkspaceConflict("registration_closed", "Регистрация завершена.") + + +def public_data(program, user): + config = published_config(program) + enrolled = bool( + user.is_authenticated + and PartnerProgramUserProfile.objects.filter( + partner_program=program, user=user + ).exists() + ) + return { + "id": program.pk, + "name": program.name, + "version": config.published_version, + "fields": config.published["fields"], + "closed": program.datetime_registration_ends < timezone.now(), + "enrolled": enrolled, + "authenticated": user.is_authenticated, + "program_url": f"/office/program/{program.pk}", + "registration_ends": program.datetime_registration_ends, + } + + +def validate_answers(schema, answers): + if not isinstance(answers, dict): + raise ValidationError({"answers": "Укажите ответы анкеты."}) + fields = {f["id"]: f for f in schema["fields"]} + if set(answers) - set(fields): + raise ValidationError( + {"answers": "В форме есть неизвестные поля. Обновите страницу."} + ) + errors, clean = {}, {} + for key, field in fields.items(): + value = answers.get(key) + if value is None or value == "" or value == []: + if field["required"]: + errors[key] = "Заполните обязательное поле." + continue + kind = field["type"] + valid = True + if kind in ("text", "textarea", "date"): + valid = ( + isinstance(value, str) + and bool(value.strip()) + and len(value) <= (10000 if kind == "textarea" else 1000) + ) + if valid and kind == "date": + try: + date.fromisoformat(value) + except ValueError: + valid = False + elif kind == "number": + valid = ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and math.isfinite(value) + and abs(value) < 1e15 + ) + else: + values = value if kind == "multiselect" else [value] + valid = ( + isinstance(values, list) + and len(values) <= 100 + and all(isinstance(v, str) and 0 < len(v) <= 500 for v in values) + ) + if valid: + if field["source"] == "own": + options = field["options"] + elif field["source"] == "universities": + options = list( + University.objects.filter( + name__in=values, is_active=True + ).values_list("name", flat=True) + ) + else: + from partner_programs.registration_regions import REGIONS + + options = REGIONS + valid = all( + v in options + or ( + field["allow_other"] + and v.startswith("Другое: ") + and bool(v[8:].strip()) + ) + for v in values + ) + if valid: + clean[key] = value.strip() if isinstance(value, str) else value + else: + errors[key] = "Укажите корректное значение из формы." + if errors: + raise ValidationError({"answers": errors}) + return clean + + +def current_answers(program, data): + check_open(program) + config = published_config(program) + if config.published_version != data["version"]: + raise WorkspaceConflict( + "form_changed", "Анкета обновлена. Проверьте ответы и отправьте её ещё раз." + ) + return config, validate_answers(config.published, data["answers"]) + + +def create_enrollment(program, user, answers, schema): + region = next( + ( + answers.get(f["id"], "") + for f in schema.get("fields", []) + if f["source"] == "regions" and f["type"] == "select" + ), + "", + ) + profile, created = PartnerProgramUserProfile.objects.get_or_create( + partner_program=program, + user=user, + defaults={ + "partner_program_data": answers, + "registration_schema": schema, + "registration_region": region + if isinstance(region, str) and not region.startswith("Другое: ") + else "", + }, + ) + return profile + + +@transaction.atomic +def enroll(pk, user, data): + if not user.is_authenticated or not user.is_active: + raise PermissionDenied("Войдите в подтверждённый аккаунт.") + program = open_program(pk, lock=True) + if PartnerProgramUserProfile.objects.filter( + partner_program=program, user=user + ).exists(): + return {"enrolled": True, "program_id": pk} + config, answers = current_answers(program, data) + create_enrollment(program, user, answers, config.published) + return {"enrolled": True, "program_id": pk} + + +def confirmation_token(pending): + return signing.dumps( + {"id": str(pending.pk), "generation": pending.generation}, salt=SALT + ) + + +def receipt_token(pending): + return signing.dumps({"id": str(pending.pk)}, salt=RECEIPT_SALT) + + +def queue_confirmation(pending): + from partner_programs.tasks import send_program_registration_confirmation + + def dispatch(): + try: + send_program_registration_confirmation.delay( + str(pending.pk), pending.generation + ) + except Exception: + PendingProgramRegistration.objects.filter(pk=pending.pk).update( + delivery_status="failed" + ) + + transaction.on_commit(dispatch) + + +@transaction.atomic +def signup(pk, data): + program = open_program(pk, lock=True) + config, answers = current_answers(program, data) + email = data["email"].strip().lower() + if CustomUser.objects.filter(email__iexact=email).exists(): + raise WorkspaceConflict( + "account_exists", + "Аккаунт с этой почтой уже есть. Войдите, чтобы участвовать.", + ) + try: + with transaction.atomic(): + user = CustomUser.objects.create_user( + email=email, + password=data["password"], + first_name=data["first_name"], + last_name=data["last_name"], + birthday=data["birthday"], + is_active=False, + onboarding_stage=None, + ) + pending = PendingProgramRegistration.objects.create( + program=program, user=user, answers=answers, schema=config.published + ) + except IntegrityError: + raise WorkspaceConflict( + "account_exists", "Аккаунт уже создан. Войдите, чтобы участвовать." + ) + queue_confirmation(pending) + return {"email_pending": True, "receipt": receipt_token(pending), "email": email} + + +@transaction.atomic +def confirm(pk, token): + try: + proof = signing.loads(token, salt=SALT, max_age=86400) + except signing.BadSignature: + raise WorkspaceConflict( + "confirmation_expired", + "Ссылка недействительна или истекла. Отправьте письмо ещё раз.", + ) + program = open_program(pk, lock=True) + pending = get_object_or_404( + PendingProgramRegistration.objects.select_for_update().select_related("user"), + pk=proof.get("id"), + program=program, + ) + if pending.confirmed_at or pending.generation != proof.get("generation"): + raise WorkspaceConflict( + "confirmation_used", "Ссылка уже использована. Войдите в аккаунт." + ) + user = pending.user + user.is_active = True + user.save(update_fields=["is_active"]) + config = config_for(program) + can_enroll = bool( + config + and config.published + and config.published["mode"] == "native" + and program.datetime_registration_ends >= timezone.now() + ) + review_required = False + if PartnerProgramUserProfile.objects.filter( + partner_program=program, user=user + ).exists(): + can_enroll = True + elif can_enroll: + current_keys = {f["id"] for f in config.published["fields"]} + try: + answers = validate_answers( + config.published, + {k: v for k, v in pending.answers.items() if k in current_keys}, + ) + except ValidationError: + can_enroll, review_required = False, True + else: + create_enrollment(program, user, answers, config.published) + pending.confirmed_at = timezone.now() + pending.save(update_fields=["confirmed_at"]) + refresh = RefreshToken.for_user(user) + return { + "access": str(refresh.access_token), + "refresh": str(refresh), + "program_id": pk, + "enrolled": can_enroll, + "review_required": review_required, + "answers": pending.answers if review_required else {}, + } + + +@transaction.atomic +def resend(pk, token): + try: + proof = signing.loads(token, salt=RECEIPT_SALT, max_age=7 * 86400) + except signing.BadSignature: + try: + proof = signing.loads(token, salt=SALT, max_age=7 * 86400) + except signing.BadSignature: + raise ValidationError( + "Ссылка недействительна. Используйте вход или восстановление пароля." + ) + program = open_program(pk, lock=True) + pending = get_object_or_404( + PendingProgramRegistration.objects.select_for_update(), + pk=proof.get("id"), + program=program, + ) + if pending.confirmed_at: + raise WorkspaceConflict( + "confirmation_used", "Почта уже подтверждена. Войдите в аккаунт." + ) + if pending.last_sent_at > timezone.now() - timedelta(seconds=60): + raise WorkspaceConflict( + "resend_wait", "Повторное письмо можно отправить через минуту." + ) + pending.generation += 1 + pending.last_sent_at = timezone.now() + pending.delivery_status = "queued" + pending.save(update_fields=["generation", "last_sent_at", "delivery_status"]) + queue_confirmation(pending) + return {"email_pending": True, "receipt": receipt_token(pending)} + + +def import_options(upload): + if upload.size > 2 * 1024 * 1024: + raise ValidationError("Размер файла должен быть не больше 2 МБ.") + raw = upload.read() + try: + if upload.name.lower().endswith(".csv"): + content = raw.decode("utf-8-sig") + dialect = ( + csv.Sniffer().sniff(content[:4096], delimiters=",;\t") + if any(c in content for c in ",;\t") + else csv.excel + ) + rows = csv.reader(io.StringIO(content), dialect) + elif upload.name.lower().endswith(".xlsx"): + with ZipFile(io.BytesIO(raw)) as archive: + if sum(i.file_size for i in archive.infolist()) > 20 * 1024 * 1024: + raise ValueError("Archive too large") + from openpyxl import load_workbook + + book = load_workbook(io.BytesIO(raw), read_only=True, data_only=False) + rows = book.active.iter_rows(values_only=True) + else: + raise ValueError("Unsupported file") + options, seen, duplicates, blanks = [], set(), 0, 0 + for index, row in enumerate(rows): + if index >= 10000 or len(row) > 1: + raise ValueError("One column and at most 10000 rows required") + value = str(row[0]).strip() if row and row[0] is not None else "" + if len(value) > 500 or value.startswith("="): + raise ValueError("Invalid option") + if not value: + blanks += 1 + elif value.casefold() in seen: + duplicates += 1 + else: + options.append(value) + seen.add(value.casefold()) + if len(options) > 5000: + raise ValueError("Too many options") + if not options: + raise ValueError("Empty list") + return {"options": options, "duplicates": duplicates, "blanks": blanks} + except ( + ValueError, + UnicodeError, + BadZipFile, + csv.Error, + KeyError, + OSError, + TypeError, + ): + raise ValidationError( + "Не удалось прочитать файл. Нужен XLSX/CSV: один столбец без заголовка, до 5000 вариантов." + ) diff --git a/partner_programs/services/participants_excel.py b/partner_programs/services/participants_excel.py index 52538179..aa7ed87b 100644 --- a/partner_programs/services/participants_excel.py +++ b/partner_programs/services/participants_excel.py @@ -61,6 +61,26 @@ def export_participants(rows): cells.append(cell) sheet.append(cells) sheet.auto_filter.ref = f"A1:F{number + 1}" + answers_sheet = workbook.create_sheet("Анкеты") + answers_sheet.append(["Участник", "Почта", "Поле анкеты", "Ответ"]) + for profile in rows.iterator(chunk_size=500): + for field in profile.registration_schema.get("fields", []): + value = profile.partner_program_data.get(field["id"]) + values = [ + person_name(profile.user), + profile.user.email, + field["label"], + ", ".join(map(str, value)) if isinstance(value, list) else value, + ] + cells = [] + for item in values: + cell = WriteOnlyCell( + answers_sheet, + ILLEGAL_CHARACTERS_RE.sub("", str(item if item is not None else "—")), + ) + cell.data_type = "s" + cells.append(cell) + answers_sheet.append(cells) stream = BytesIO() workbook.save(stream) return stream.getvalue() diff --git a/partner_programs/services/registration.py b/partner_programs/services/registration.py index 4d7be8d4..58f422f4 100644 --- a/partner_programs/services/registration.py +++ b/partner_programs/services/registration.py @@ -41,6 +41,13 @@ def register_user_to_program( user: User, data, ) -> PartnerProgramUserProfile: + from partner_programs.services.native_registration import config_for + + config = config_for(program) + if config and config.published and config.published["mode"] == "native": + raise ProgramRegistrationError( + "Используйте опубликованную анкету регистрации программы." + ) if program.datetime_registration_ends < timezone.now(): raise ProgramRegistrationError("Registration period has ended.") @@ -62,6 +69,19 @@ def create_user_and_register_to_program( program: PartnerProgram, data, ) -> PartnerProgramUserProfile: + from partner_programs.services.native_registration import config_for + + config = config_for(program) + if ( + config + and config.published + and ( + config.published["mode"] == "native" or config.external_status != "connected" + ) + ): + raise ProgramRegistrationError( + "Используйте действующий способ регистрации программы." + ) email = data.get("email") if data.get("email") else data.get("email_") if not email: raise ProgramRegistrationError("You need to pass an email address.") @@ -99,9 +119,7 @@ def create_user_and_register_to_program( partner_program=program, ) except IntegrityError: - raise ProgramRegistrationError( - "User has already registered in this program." - ) + raise ProgramRegistrationError("User has already registered in this program.") _send_program_registration_email(user, program) return user_profile diff --git a/partner_programs/tasks.py b/partner_programs/tasks.py index 57d85266..7e23c6f0 100644 --- a/partner_programs/tasks.py +++ b/partner_programs/tasks.py @@ -6,6 +6,48 @@ logger = logging.getLogger(__name__) +@app.task(autoretry_for=(Exception,), retry_backoff=True, max_retries=3) +def send_program_registration_confirmation(pending_id, generation): + from django.conf import settings + from django.core.mail import EmailMultiAlternatives + from django.template.loader import render_to_string + from urllib.parse import urlencode + from partner_programs.models import PendingProgramRegistration + from partner_programs.services.native_registration import ( + confirmation_token, + frontend_url, + ) + + rows = PendingProgramRegistration.objects.filter( + pk=pending_id, generation=generation, confirmed_at__isnull=True + ) + pending = rows.select_related("program", "user").first() + if not pending: + return + context = { + "program_name": pending.program.name, + "first_name": pending.user.first_name, + "confirmation_url": f"{frontend_url()}/program-registration/{pending.program_id}#" + + urlencode({"confirmation": confirmation_token(pending)}), + } + try: + message = EmailMultiAlternatives( + "Подтвердите регистрацию в программе | PROCOLLAB", + render_to_string("email/program_registration.txt", context), + settings.EMAIL_USER, + [pending.user.email], + ) + message.attach_alternative( + render_to_string("email/program_registration.html", context), "text/html" + ) + if not message.send(): + raise RuntimeError("Email delivery did not complete") + rows.update(delivery_status="sent") + except Exception: + rows.update(delivery_status="failed") + raise + + @app.task def publish_finished_program_projects_task() -> int: updated_count = publish_finished_program_projects() diff --git a/partner_programs/tests/test_native_registration.py b/partner_programs/tests/test_native_registration.py new file mode 100644 index 00000000..d368cc00 --- /dev/null +++ b/partner_programs/tests/test_native_registration.py @@ -0,0 +1,539 @@ +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from django.db import connections +from django.test import TransactionTestCase, skipUnlessDBFeature +import copy +import io +from datetime import timedelta +from unittest.mock import patch + +from django.core import mail +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.utils import timezone +from openpyxl import Workbook, load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramUserProfile, + ProgramRegistration, + PendingProgramRegistration, +) +from partner_programs.services.native_registration import confirmation_token +from users.models import CustomUser + + +TEST_REGISTRATION_SETTINGS = { + "EMAIL_BACKEND": "django.core.mail.backends.locmem.EmailBackend", + "EMAIL_USER": "no-reply@example.test", + "CELERY_TASK_ALWAYS_EAGER": True, + "CELERY_TASK_EAGER_PROPAGATES": True, + "CELERY_BROKER_URL": "memory://", + "CELERY_RESULT_BACKEND": "cache+memory://", + "PASSWORD_HASHERS": ["django.contrib.auth.hashers.MD5PasswordHasher"], +} + + +@override_settings(**TEST_REGISTRATION_SETTINGS) +class NativeRegistrationTests(TestCase): + def setUp(self): + from django.core.cache import cache + + cache.clear() + self.client = APIClient() + self.manager = CustomUser.objects.create_user( + email="manager@example.test", + password="manager-password", + first_name="Иван", + last_name="Иванов", + birthday="2000-01-01", + is_active=True, + ) + self.participant = CustomUser.objects.create_user( + email="member@example.test", + password="member-password", + first_name="Анна", + last_name="Иванова", + birthday="2000-01-01", + is_active=True, + ) + now = timezone.now() + self.program = PartnerProgram.objects.create( + name="Тестовая программа", + city="Москва", + tag="тест", + draft=False, + datetime_started=now, + datetime_finished=now + timedelta(days=30), + datetime_registration_ends=now + timedelta(days=10), + ) + self.program.managers.add(self.manager) + self.field = { + "id": "f" + "a" * 32, + "label": "Вуз", + "type": "select", + "help_text": "", + "required": True, + "source": "own", + "options": ["Первый вуз", "Второй вуз"], + "allow_other": False, + } + self.schema = {"mode": "native", "fields": [self.field]} + self.config = ProgramRegistration.objects.create( + program=self.program, + draft=self.schema, + published=self.schema, + published_version=1, + ) + self.root = f"/programs/{self.program.pk}" + + def data(self): + return {"version": 1, "answers": {self.field["id"]: "Первый вуз"}} + + def signup(self): + return { + **self.data(), + "first_name": "Мария", + "last_name": "Иванова", + "email": "new@example.test", + "birthday": "2000-01-01", + "password": "good-secure-pass-2030", + "repeat_password": "good-secure-pass-2030", + "accept_policy": True, + "accept_age": True, + } + + def post(self, path, data): + return self.client.post(self.root + path, data, format="json") + + def test_public_form_hides_drafts(self): + self.assertEqual(self.client.get(self.root + "/registration/").status_code, 200) + self.config.published = None + self.config.save() + self.assertEqual(self.client.get(self.root + "/registration/").status_code, 400) + self.program.draft = True + self.program.save() + self.assertEqual(self.client.get(self.root + "/registration/").status_code, 404) + + def test_organizer_permissions_and_stale_draft(self): + self.client.force_authenticate(self.participant) + self.assertEqual( + self.client.get(self.root + "/management/registration/").status_code, 403 + ) + self.client.force_authenticate(self.manager) + data = {"revision": 0, "draft": self.schema} + self.assertEqual( + self.client.put( + self.root + "/management/registration/", data, format="json" + ).status_code, + 200, + ) + self.assertEqual( + self.client.put( + self.root + "/management/registration/", data, format="json" + ).status_code, + 409, + ) + + def test_draft_missing_options_can_save_but_not_publish(self): + self.client.force_authenticate(self.manager) + schema = copy.deepcopy(self.schema) + schema["fields"][0]["options"] = [] + self.assertEqual( + self.client.put( + self.root + "/management/registration/", + {"revision": 0, "draft": schema}, + format="json", + ).status_code, + 200, + ) + self.assertEqual( + self.post( + "/management/registration/publish/", {"revision": 1, "draft": schema} + ).status_code, + 400, + ) + self.assertEqual( + self.client.get(self.root + "/registration/").data["fields"][0]["options"], + self.field["options"], + ) + + def test_public_submit_needs_authenticated_owner(self): + self.assertEqual(self.post("/registration/", self.data()).status_code, 403) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + self.client.force_authenticate(self.participant) + self.assertEqual(self.post("/registration/", self.data()).status_code, 200) + self.assertEqual(PartnerProgramUserProfile.objects.get().user, self.participant) + + def test_existing_email_requires_login_and_never_changes_profile(self): + data = self.signup() + data["email"] = self.participant.email.upper() + self.assertEqual(self.post("/registration/signup/", data).status_code, 409) + self.participant.refresh_from_db() + self.assertEqual(self.participant.first_name, "Анна") + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + + def test_signup_pending_confirm_single_use(self): + with self.captureOnCommitCallbacks(execute=True): + response = self.post("/registration/signup/", self.signup()) + self.assertEqual(response.status_code, 201, response.data) + self.assertNotIn("access", response.data) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + pending = PendingProgramRegistration.objects.get() + self.assertFalse(pending.user.is_active) + self.assertIsNone(pending.user.verification_date) + self.assertIsNone(pending.user.onboarding_stage) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + f"/program-registration/{self.program.pk}#confirmation=", mail.outbox[0].body + ) + result = self.post( + "/registration/confirm/", {"token": confirmation_token(pending)} + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertIn("access", result.data) + profile = PartnerProgramUserProfile.objects.get() + self.assertEqual(profile.partner_program_data, self.data()["answers"]) + self.assertEqual(profile.registration_schema, self.schema) + self.assertEqual( + self.post( + "/registration/confirm/", {"token": confirmation_token(pending)} + ).status_code, + 409, + ) + + def test_dob_policy_password_are_required(self): + for key, value in ( + ("birthday", None), + ("accept_policy", False), + ("repeat_password", "different"), + ("first_name", "bad123"), + ): + data = self.signup() + data[key] = value + self.assertEqual( + self.post("/registration/signup/", data).status_code, 400, key + ) + self.assertFalse(PendingProgramRegistration.objects.exists()) + + def test_invalid_values_and_stale_publication(self): + self.client.force_authenticate(self.participant) + for answers in ({}, {self.field["id"]: "Невалидный вуз"}, {"unknown": "value"}): + self.assertEqual( + self.post( + "/registration/", {"version": 1, "answers": answers} + ).status_code, + 400, + ) + self.assertEqual( + self.post("/registration/", {**self.data(), "version": 2}).status_code, 409 + ) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + + def test_closed_new_enrollment_blocked_existing_idempotent(self): + self.client.force_authenticate(self.participant) + self.assertEqual(self.post("/registration/", self.data()).status_code, 200) + self.program.datetime_registration_ends = timezone.now() - timedelta(seconds=1) + self.program.save() + self.assertEqual(self.post("/registration/", self.data()).status_code, 200) + self.client.force_authenticate(None) + self.assertEqual( + self.post("/registration/signup/", self.signup()).status_code, 409 + ) + self.assertEqual(PartnerProgramUserProfile.objects.count(), 1) + + def test_confirmation_after_closure_activates_account_without_enrollment(self): + self.post("/registration/signup/", self.signup()) + pending = PendingProgramRegistration.objects.get() + self.program.datetime_registration_ends = timezone.now() - timedelta(seconds=1) + self.program.save() + result = self.post( + "/registration/confirm/", {"token": confirmation_token(pending)} + ) + self.assertEqual(result.status_code, 200) + self.assertFalse(result.data["enrolled"]) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + pending.user.refresh_from_db() + self.assertTrue(pending.user.is_active) + + def test_resend_receipt_limited_and_invalidates_old_link(self): + response = self.post("/registration/signup/", self.signup()) + pending = PendingProgramRegistration.objects.get() + old = confirmation_token(pending) + self.assertEqual( + self.post( + "/registration/resend/", {"token": response.data["receipt"]} + ).status_code, + 409, + ) + PendingProgramRegistration.objects.filter(pk=pending.pk).update( + last_sent_at=timezone.now() - timedelta(minutes=2) + ) + self.assertEqual( + self.post( + "/registration/resend/", {"token": response.data["receipt"]} + ).status_code, + 200, + ) + self.assertEqual( + self.post("/registration/confirm/", {"token": old}).status_code, 409 + ) + self.assertEqual( + self.post("/registration/resend/", {"token": "bogus"}).status_code, 400 + ) + + def test_expired_confirmation_cannot_issue_tokens(self): + self.post("/registration/signup/", self.signup()) + pending = PendingProgramRegistration.objects.get() + with patch("django.core.signing.time.time", return_value=1): + token = confirmation_token(pending) + self.assertEqual( + self.post("/registration/confirm/", {"token": token}).status_code, 409 + ) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + + def test_archive_retains_old_answers_and_export(self): + self.client.force_authenticate(self.participant) + self.post("/registration/", self.data()) + self.client.force_authenticate(self.manager) + response = self.post( + "/management/registration/publish/", + {"revision": 0, "draft": {"mode": "native", "fields": []}}, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertIn(self.field["id"], response.data["answered_field_ids"]) + profile = PartnerProgramUserProfile.objects.get() + self.assertEqual(profile.partner_program_data, self.data()["answers"]) + result = self.client.get(self.root + "/management/participants/export/") + book = load_workbook(io.BytesIO(result.content)) + self.assertEqual(list(book["Анкеты"].values)[1][-2:], ("Вуз", "Первый вуз")) + + def test_import_preview_duplicates_blanks_and_errors_does_not_save(self): + self.client.force_authenticate(self.manager) + result = self.client.post( + self.root + "/management/registration/import/", + { + "file": SimpleUploadedFile( + "list.csv", "Первый\n\nПервый\nВторой\n".encode() + ) + }, + format="multipart", + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual( + result.data, {"options": ["Первый", "Второй"], "duplicates": 1, "blanks": 1} + ) + for filename, content in ( + ("list.xlsx", b"broken"), + ("list.csv", b"one,two\n"), + ("list.exe", b"bad"), + ): + result = self.client.post( + self.root + "/management/registration/import/", + {"file": SimpleUploadedFile(filename, content)}, + format="multipart", + ) + self.assertEqual(result.status_code, 400) + self.config.refresh_from_db() + self.assertEqual(self.config.draft, self.schema) + + def test_xlsx_one_column_and_formulas_blocked(self): + self.client.force_authenticate(self.manager) + for value, expected in (("Университет", 200), ("=1+1", 400)): + book = Workbook() + book.active.append([value]) + buffer = io.BytesIO() + book.save(buffer) + response = self.client.post( + self.root + "/management/registration/import/", + {"file": SimpleUploadedFile("list.xlsx", buffer.getvalue())}, + format="multipart", + ) + self.assertEqual(response.status_code, expected, response.data) + + def test_external_connection_request_cannot_self_approve(self): + self.client.force_authenticate(self.manager) + response = self.post( + "/management/registration/connection/", + {"url": "https://example.test/registration", "comment": "Форма Тильды"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["external_status"], "requested") + self.assertEqual( + self.post( + "/management/registration/publish/", + {"revision": 1, "draft": {"mode": "external", "fields": []}}, + ).status_code, + 400, + ) + self.assertEqual( + self.post( + "/management/registration/connection/", + { + "url": "https://example.test", + "comment": "", + "external_status": "connected", + }, + ).status_code, + 400, + ) + + def test_legacy_registration_cannot_bypass_published_native_schema(self): + self.client.force_authenticate(self.participant) + self.assertEqual(self.post("/register/", {}).status_code, 400) + self.client.force_authenticate(None) + self.assertEqual(self.post("/register_new/", self.signup()).status_code, 400) + + def test_new_required_fields_before_confirmation_require_review(self): + self.post("/registration/signup/", self.signup()) + pending = PendingProgramRegistration.objects.get() + newer = copy.deepcopy(self.schema) + newer["fields"].append({**self.field, "id": "f" + "b" * 32, "label": "Курс"}) + self.config.published = newer + self.config.published_version = 2 + self.config.save() + result = self.post( + "/registration/confirm/", {"token": confirmation_token(pending)} + ) + self.assertEqual(result.status_code, 200, result.data) + self.assertTrue(result.data["review_required"]) + self.assertFalse(result.data["enrolled"]) + self.assertEqual(result.data["answers"], self.data()["answers"]) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + + def test_region_filter_uses_program_answer_without_overwriting_profile(self): + schema = { + "mode": "native", + "fields": [{**self.field, "source": "regions", "options": []}], + } + self.config.published = schema + self.config.save() + self.client.force_authenticate(self.participant) + result = self.post( + "/registration/", {"version": 1, "answers": {self.field["id"]: "Москва"}} + ) + self.assertEqual(result.status_code, 200, result.data) + self.participant.refresh_from_db() + self.assertFalse(self.participant.city) + self.client.force_authenticate(self.manager) + result = self.client.get( + self.root + "/management/participants/?region_scope=selected®ion=Москва" + ) + self.assertEqual(result.data["count"], 1) + self.assertEqual(result.data["results"][0]["region"], "Москва") + + def test_invalid_json_document_is_400(self): + self.assertEqual( + self.post("/registration/signup/", [{"bad": True}]).status_code, 400 + ) + + def test_stale_browser_token_does_not_hide_public_form(self): + self.client.credentials(HTTP_AUTHORIZATION="Bearer expired") + result = self.client.get(self.root + "/registration/") + self.assertEqual(result.status_code, 200) + self.assertFalse(result.data["authenticated"]) + + def test_confirmation_does_not_work_for_another_program(self): + self.post("/registration/signup/", self.signup()) + pending = PendingProgramRegistration.objects.get() + original = self.program.pk + self.program.pk = None + self.program.save() + result = self.client.post( + f"/programs/{self.program.pk}/registration/confirm/", + {"token": confirmation_token(pending)}, + format="json", + ) + self.assertEqual(result.status_code, 404) + self.assertFalse( + PartnerProgramUserProfile.objects.filter(partner_program_id=original).exists() + ) + + def test_list_number_date_and_other_validation(self): + from partner_programs.services.native_registration import validate_answers + from rest_framework.exceptions import ValidationError + + for kind, value in ( + ("number", True), + ("date", "2026-99-99"), + ("multiselect", ["Невалидный"]), + ("select", "Другое: "), + ): + field = {**self.field, "type": kind, "allow_other": True} + with self.assertRaises(ValidationError): + validate_answers({"fields": [field]}, {field["id"]: value}) + self.assertEqual( + validate_answers( + {"fields": [{**self.field, "allow_other": True}]}, + {self.field["id"]: "Другое: Третий вуз"}, + )[self.field["id"]], + "Другое: Третий вуз", + ) + + def test_native_signup_preserves_password_reset_program_context(self): + self.post("/registration/signup/", self.signup()) + self.client.post( + "/auth/reset_password/", + {"email": self.participant.email, "registration_program_id": self.program.pk}, + format="json", + ) + self.assertIn("/program-password-reset/?", mail.outbox[-1].body) + self.assertIn(f"registration_program={self.program.pk}", mail.outbox[-1].body) + + +@skipUnlessDBFeature("has_select_for_update") +@override_settings(**TEST_REGISTRATION_SETTINGS) +class NativeRegistrationConcurrencyTests(TransactionTestCase): + setUp = NativeRegistrationTests.setUp + data = NativeRegistrationTests.data + signup = NativeRegistrationTests.signup + + def parallel(self, calls): + gate = Barrier(2) + + def run(call): + connections.close_all() + try: + gate.wait(timeout=10) + return call() + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + return list(pool.map(run, calls)) + + def test_double_signup_creates_one_pending_account(self): + def signup(): + return ( + APIClient() + .post(self.root + "/registration/signup/", self.signup(), format="json") + .status_code + ) + + self.assertEqual(sorted(self.parallel([signup, signup])), [201, 409]) + self.assertEqual(PendingProgramRegistration.objects.count(), 1) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + + def test_double_enrollment_is_idempotent(self): + def enroll(): + client = APIClient() + client.force_authenticate(self.participant) + return client.post( + self.root + "/registration/", self.data(), format="json" + ).status_code + + self.assertEqual(self.parallel([enroll, enroll]), [200, 200]) + self.assertEqual(PartnerProgramUserProfile.objects.count(), 1) + + def test_concurrent_publish_has_one_winner(self): + def publish(): + client = APIClient() + client.force_authenticate(self.manager) + return client.post( + self.root + "/management/registration/publish/", + {"revision": 0, "draft": self.schema}, + format="json", + ).status_code + + self.assertEqual(sorted(self.parallel([publish, publish])), [200, 409]) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 2ef2057c..83adedbf 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,4 +1,12 @@ from django.urls import path +from partner_programs.registration_views import ( + RegistrationSettingsView, + RegistrationPublishView, + RegistrationImportView, + RegistrationConnectionView, + PublicRegistrationView, + PublicRegistrationActionView, +) from partner_programs.organizer_views import ( OrganizersView, OrganizerAccessView, @@ -70,6 +78,15 @@ app_name = "partner_programs" urlpatterns = [ + path("/management/registration/", RegistrationSettingsView.as_view()), + path("/management/registration/publish/", RegistrationPublishView.as_view()), + path("/management/registration/import/", RegistrationImportView.as_view()), + path( + "/management/registration/connection/", + RegistrationConnectionView.as_view(), + ), + path("/registration/", PublicRegistrationView.as_view()), + path("/registration//", PublicRegistrationActionView.as_view()), path("organizer-invitations//", PublicOrganizerInvitationView.as_view()), path( "organizer-invitations///", diff --git a/partner_programs/views.py b/partner_programs/views.py index c1b40781..7428b2dd 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -108,7 +108,9 @@ class PartnerProgramList(generics.ListCreateAPIView): - queryset = PartnerProgram.objects.filter(draft=False) + queryset = PartnerProgram.objects.filter(draft=False).select_related( + "registration_config" + ) serializer_class = PartnerProgramListSerializer permission_classes = [permissions.IsAuthenticatedOrReadOnly] pagination_class = PartnerProgramPagination @@ -139,11 +141,15 @@ def get_queryset(self): class PartnerProgramDetail(generics.RetrieveAPIView): - queryset = PartnerProgram.objects.prefetch_related( - "materials", - "managers", - "courses", - ).all() + queryset = ( + PartnerProgram.objects.select_related("registration_config") + .prefetch_related( + "materials", + "managers", + "courses", + ) + .all() + ) permission_classes = [permissions.IsAuthenticatedOrReadOnly] serializer_class = PartnerProgramForUnregisteredUserSerializer diff --git a/templates/email/program_registration.html b/templates/email/program_registration.html new file mode 100644 index 00000000..54c1e676 --- /dev/null +++ b/templates/email/program_registration.html @@ -0,0 +1,7 @@ + +
+

Подтвердите регистрацию

Здравствуйте, {{ first_name }}!

+

Вы заполнили анкету программы «{{ program_name }}». Ответы сохранены.

+

Подтвердить почту и перейти к программе

+

Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.

+

Команда PROCOLLAB

diff --git a/templates/email/program_registration.txt b/templates/email/program_registration.txt new file mode 100644 index 00000000..6c1b7a95 --- /dev/null +++ b/templates/email/program_registration.txt @@ -0,0 +1,9 @@ +Здравствуйте, {{ first_name }}! + +Подтвердите почту для участия в программе «{{ program_name }}»: +{{ confirmation_url }} + +Анкета уже сохранена. После подтверждения откроется программа. +Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо. + +Команда PROCOLLAB diff --git a/users/signals.py b/users/signals.py index 2fe73bd7..b50f47c8 100644 --- a/users/signals.py +++ b/users/signals.py @@ -49,6 +49,28 @@ def password_reset_token_created(sender, instance, reset_password_token, *args, reset_password_url = ( f"https://app.procollab.ru/auth/reset_password/?token={reset_password_token.key}" ) + registration_program = getattr(getattr(instance, "request", None), "data", {}).get( + "registration_program_id" + ) + if str(registration_program).isdigit() and len(str(registration_program)) <= 10: + from partner_programs.models import PartnerProgram + from urllib.parse import urlencode + + if PartnerProgram.objects.filter( + pk=int(registration_program), + draft=False, + registration_config__published__mode="native", + ).exists(): + reset_password_url = ( + settings.EXPERT_WORKSPACE_FRONTEND_URL.rstrip("/") + + "/program-password-reset/?" + + urlencode( + { + "token": reset_password_token.key, + "registration_program": registration_program, + } + ) + ) # Invitation-specific return context; never accept an arbitrary redirect origin. invitation_token = getattr(getattr(instance, "request", None), "data", {}).get( "expert_invitation_token"