From 4651046b79d35ebd483f478605376237863a60bc Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 01:23:48 +0300 Subject: [PATCH 1/7] feat(programs): create private drafts and publish registration atomically --- partner_programs/certificate_views.py | 2 + .../0040_program_creation_publication.py | 93 ++++ partner_programs/models.py | 42 ++ partner_programs/permissions.py | 72 ++- partner_programs/program_launch_views.py | 75 +++ partner_programs/serializers/management.py | 7 + partner_programs/services/certificates.py | 6 +- partner_programs/services/management.py | 2 + .../services/native_registration.py | 10 +- partner_programs/services/program_launch.py | 282 +++++++++++ partner_programs/services/project_fields.py | 4 +- partner_programs/services/registration.py | 6 + partner_programs/solution_api.py | 13 +- partner_programs/tests/test_program_launch.py | 473 ++++++++++++++++++ .../tests/test_solution_creation_switch.py | 17 +- .../tests/test_solution_dev_migrations.py | 4 +- partner_programs/urls.py | 427 +++++++++++----- partner_programs/views.py | 41 +- projects/permissions.py | 10 +- projects/team_policy.py | 6 +- projects/team_service.py | 1 + 21 files changed, 1436 insertions(+), 157 deletions(-) create mode 100644 partner_programs/migrations/0040_program_creation_publication.py create mode 100644 partner_programs/program_launch_views.py create mode 100644 partner_programs/services/program_launch.py create mode 100644 partner_programs/tests/test_program_launch.py diff --git a/partner_programs/certificate_views.py b/partner_programs/certificate_views.py index db1f7749..9510cd01 100644 --- a/partner_programs/certificate_views.py +++ b/partner_programs/certificate_views.py @@ -143,6 +143,8 @@ def post(self, request, pk): "Сначала завершите предыдущую выдачу: возобновите очередь и повторите ошибки." ) issue_at = timezone.now() + if program.datetime_finished is None: + raise ValidationError("Укажите окончание программы перед запуском выдачи.") if draft["timing"] == "scheduled": issue_at = parse_datetime(draft["scheduled_at"] or "") if ( diff --git a/partner_programs/migrations/0040_program_creation_publication.py b/partner_programs/migrations/0040_program_creation_publication.py new file mode 100644 index 00000000..aa446708 --- /dev/null +++ b/partner_programs/migrations/0040_program_creation_publication.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.11 on 2026-10-26 21:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0039_merge_solution_certificate_branches"), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="created_by", + field=models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_partner_programs", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="creation_fingerprint", + field=models.CharField(blank=True, editable=False, max_length=64, null=True), + ), + migrations.AddField( + model_name="partnerprogram", + name="creation_key", + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name="partnerprogram", + name="program_format", + field=models.CharField( + blank=True, + choices=[ + ("case_championship", "Кейс-чемпионат"), + ("hackathon", "Хакатон"), + ("noncompetitive", "Неконкурсная программа"), + ], + max_length=24, + null=True, + verbose_name="Формат программы", + ), + ), + migrations.AlterField( + model_name="partnerprogram", + name="datetime_finished", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата окончания" + ), + ), + migrations.AlterField( + model_name="partnerprogram", + name="datetime_registration_ends", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Дата окончания регистрации" + ), + ), + migrations.AlterField( + model_name="partnerprogram", + name="datetime_started", + field=models.DateTimeField(blank=True, null=True, verbose_name="Дата начала"), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.UniqueConstraint( + fields=("created_by", "creation_key"), name="program_creation_idempotency" + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("draft", True), + models.Q( + ("datetime_started__isnull", False), + ("datetime_finished__isnull", False), + ("datetime_registration_ends__isnull", False), + ), + _connector="OR", + ), + name="published_program_requires_dates", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 4c439fef..8419f4c0 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -229,6 +229,29 @@ class PartnerProgram(models.Model): help_text="Пользователи, имеющие право создавать и редактировать новости", ) draft = models.BooleanField(blank=False, default=True) + program_format = models.CharField( + max_length=24, + choices=[ + ("case_championship", "Кейс-чемпионат"), + ("hackathon", "Хакатон"), + ("noncompetitive", "Неконкурсная программа"), + ], + null=True, + blank=True, + verbose_name="Формат программы", + ) + created_by = models.ForeignKey( + User, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_partner_programs", + editable=False, + ) + creation_key = models.UUIDField(null=True, blank=True, editable=False) + creation_fingerprint = models.CharField( + max_length=64, null=True, blank=True, editable=False + ) projects_availability = models.CharField( choices=PROJECTS_AVAILABILITY_CHOISES, max_length=25, @@ -241,6 +264,8 @@ class PartnerProgram(models.Model): help_text="Если включено, проекты участников станут публичными после завершения программы", ) datetime_registration_ends = models.DateTimeField( + null=True, + blank=True, verbose_name="Дата окончания регистрации", ) datetime_project_submission_ends = models.DateTimeField( @@ -255,9 +280,13 @@ class PartnerProgram(models.Model): verbose_name="Дата окончания оценки проектов", ) datetime_started = models.DateTimeField( + null=True, + blank=True, verbose_name="Дата начала", ) datetime_finished = models.DateTimeField( + null=True, + blank=True, verbose_name="Дата окончания", ) datetime_created = models.DateTimeField( @@ -323,6 +352,19 @@ def is_manager(self, user: User) -> bool: class Meta: constraints = [ + models.UniqueConstraint( + fields=("created_by", "creation_key"), + name="program_creation_idempotency", + ), + models.CheckConstraint( + check=models.Q(draft=True) + | ( + models.Q(datetime_started__isnull=False) + & models.Q(datetime_finished__isnull=False) + & models.Q(datetime_registration_ends__isnull=False) + ), + name="published_program_requires_dates", + ), models.CheckConstraint( check=models.Q(legacy_team_min_size__isnull=True) | models.Q(legacy_team_min_size__gte=1), diff --git a/partner_programs/permissions.py b/partner_programs/permissions.py index ce9803d0..b28b38a5 100644 --- a/partner_programs/permissions.py +++ b/partner_programs/permissions.py @@ -1,8 +1,78 @@ -from rest_framework.permissions import BasePermission +from functools import wraps + +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import BasePermission, SAFE_METHODS from partner_programs.models import PartnerProgram +def can_create_program(user) -> bool: + return bool( + user + and user.is_authenticated + and user.has_perm("partner_programs.add_partnerprogram") + ) + + +def require_create_program(user): + if not can_create_program(user): + raise PermissionDenied("Администратор еще не выдал вам право создания программ.") + + +class CanCreateProgram(BasePermission): + def has_permission(self, request, view): + require_create_program(request.user) + return True + + +class ProgramDraftVisibility(BasePermission): + """One boundary for every program route, after DRF authentication.""" + + def has_permission(self, request, view): + from partner_programs.models import PartnerProgramProject + + if "partner-program-projects/" in request.path: + link = ( + PartnerProgramProject.objects.select_related("partner_program", "project") + .filter(pk=view.kwargs.get("pk")) + .first() + ) + view.visibility_program_link = link + program = link.partner_program if link else None + else: + pk = view.kwargs.get("partnerprogram_pk") or view.kwargs.get("pk") + program = PartnerProgram.objects.filter(pk=pk).first() if pk else None + # Reuse this read in read-only views, never for transactional writes. + view.visibility_program = program + if program is None or not program.draft: + return True + setattr(getattr(request, "_request", request), "_program_draft_response", True) + if not can_manage_program(request.user, program): + raise NotFound() + if request.method not in SAFE_METHODS and not ( + "/management/" in request.path or "/news/" in request.path + ): + # Previewing a draft never enrolls, views, likes or creates a solution. + raise NotFound() + return True + + +def program_view(view_class, **kwargs): + view = view_class.as_view( + permission_classes=[ProgramDraftVisibility, *view_class.permission_classes], + **kwargs, + ) + + @wraps(view) + def dispatch(request, *args, **view_kwargs): + response = view(request, *args, **view_kwargs) + if getattr(request, "_program_draft_response", False): + response["Cache-Control"] = "private, no-store" + return response + + return dispatch + + def can_manage_program(user, program: PartnerProgram) -> bool: if not user or not user.is_authenticated: return False diff --git a/partner_programs/program_launch_views.py b/partner_programs/program_launch_views.py new file mode 100644 index 00000000..0a3dbc3a --- /dev/null +++ b/partner_programs/program_launch_views.py @@ -0,0 +1,75 @@ +"""Private entry points for the organizer creation flow.""" + +from rest_framework import serializers +from rest_framework.response import Response + +from partner_programs.management_views import ManagementBase +from partner_programs.models import PartnerProgram +from partner_programs.pagination import ProgramAttentionPagination +from partner_programs.permissions import can_create_program, require_create_program +from partner_programs.serializers.management import StrictFieldsMixin +from partner_programs.services import program_launch as service + + +class CreateProgramInput(StrictFieldsMixin, serializers.Serializer): + name = serializers.CharField(max_length=255, trim_whitespace=True) + format = serializers.ChoiceField(choices=service.FORMATS) + idempotency_key = serializers.UUIDField() + + +class ManagedProgramsQuery(StrictFieldsMixin, serializers.Serializer): + search = serializers.CharField(max_length=255, allow_blank=True, default="") + limit = serializers.IntegerField(min_value=1, max_value=100, default=10) + offset = serializers.IntegerField(min_value=0, default=0) + + +class ProgramCapabilitiesView(ManagementBase): + def get(self, request): + return Response({"can_create_program": can_create_program(request.user)}) + + +class ProgramDraftCreateView(ManagementBase): + def post(self, request): + require_create_program(request.user) + body = CreateProgramInput(data=request.data) + body.is_valid(raise_exception=True) + program, created = service.create_program(request.user, body.validated_data) + return Response(service.summary(program), status=201 if created else 200) + + +class ManagedProgramsView(ManagementBase): + def get(self, request): + form = ManagedProgramsQuery(data=request.query_params) + form.is_valid(raise_exception=True) + rows = PartnerProgram.objects.all() + if not (request.user.is_staff or request.user.is_superuser): + rows = rows.filter(managers=request.user) + if form.validated_data["search"]: + rows = rows.filter(name__icontains=form.validated_data["search"]) + rows = rows.order_by("-datetime_created", "-pk") + paginator = ProgramAttentionPagination(form.validated_data) + page = paginator.paginate_queryset(rows, request, view=self) + return paginator.get_paginated_response([service.summary(p) for p in page]) + + +class ProgramPreviewView(ManagementBase): + def get(self, request, pk): + return Response(service.preview_program(pk, request.user, request)) + + +class ProgramReadinessView(ManagementBase): + def get(self, request, pk): + return Response(service.readiness(pk, request.user)) + + +class PublishProgramInput(StrictFieldsMixin, serializers.Serializer): + revision = serializers.RegexField(r"^[a-f0-9]{64}$", trim_whitespace=False) + + +class ProgramPublishView(ManagementBase): + def post(self, request, pk): + data = PublishProgramInput(data=request.data) + data.is_valid(raise_exception=True) + return Response( + service.publish_program(pk, request.user, data.validated_data["revision"]) + ) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index 8a3f2225..867fb304 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -222,6 +222,13 @@ def get_fields(self): del fields[name] for name in allowed: fields[name].required = False + if ( + name + in ("datetime_started", "datetime_finished", "datetime_registration_ends") + and self.instance is not None + and not self.instance.draft + ): + fields[name].allow_null = False if name.endswith("_address") or name == "registration_link": fields[name] = HttpURLField( required=False, allow_null=True, allow_blank=True diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index a09eaeaf..73997623 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -575,9 +575,9 @@ def initialize_batch(batch_id): ) if not batch.active or batch.issue_at > timezone.now() or batch.initialized_at: return - if ( - batch.snapshot["timing"] == "scheduled" - and batch.program.datetime_finished > timezone.now() + if batch.snapshot["timing"] == "scheduled" and ( + batch.program.datetime_finished is None + or batch.program.datetime_finished > timezone.now() ): return rows = [ diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index ec6b5f45..b87fd020 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -66,6 +66,8 @@ def material_data(material): def management_data(program): return { "id": program.pk, + "draft": program.draft, + "program_format": program.program_format, **{name: getattr(program, name) for name in (*BASIC_FIELDS, *DATE_FIELDS)}, "updated_at": program.datetime_updated, "is_competitive": program.is_competitive, diff --git a/partner_programs/services/native_registration.py b/partner_programs/services/native_registration.py index 2c028575..47068711 100644 --- a/partner_programs/services/native_registration.py +++ b/partner_programs/services/native_registration.py @@ -134,6 +134,7 @@ def clean_schema(data, *, publishing=False): def settings_data(program): config = config_for(program) return { + "program_draft": program.draft, "draft": config.draft if config else initial_draft(program), "published": config.published if config else None, "revision": config.revision if config else 0, @@ -188,13 +189,12 @@ def save_settings(pk, user, data, *, publish=False): 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 + if not program.draft: + config.published = copy.deepcopy(schema) + config.published_at = timezone.now() + config.published_version += 1 config.save() return settings_data(program) diff --git a/partner_programs/services/program_launch.py b/partner_programs/services/program_launch.py new file mode 100644 index 00000000..d723d0e8 --- /dev/null +++ b/partner_programs/services/program_launch.py @@ -0,0 +1,282 @@ +"""Creation and first publication; existing program editors remain authoritative.""" + +import hashlib +import json +import copy +from html import unescape + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.utils import timezone +from django.utils.html import strip_tags +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError as DjangoValidationError +from rest_framework.exceptions import ValidationError + +from partner_programs.models import PartnerProgram, ProgramRegistration +from partner_programs.permissions import require_create_program +from partner_programs.services.management import managed_program +from partner_programs.serializers.management import BASIC_FIELDS, DATE_FIELDS +from partner_programs.services import native_registration as registration +from project_rates.workspace import WorkspaceConflict + +FORMATS = ("case_championship", "hackathon", "noncompetitive") + + +def summary(program): + return { + "id": program.pk, + "name": program.name, + "draft": program.draft, + "program_format": program.program_format, + "solution_storage_mode": program.solution_storage_mode, + "updated_at": program.datetime_updated, + "datetime_started": program.datetime_started, + "datetime_finished": program.datetime_finished, + "datetime_registration_ends": program.datetime_registration_ends, + } + + +@transaction.atomic +def create_program(user, data): + # Serialize retries per creator; the database constraint also covers other writers. + actor = get_user_model().objects.select_for_update().get(pk=user.pk) + require_create_program(actor) + fingerprint = hashlib.sha256( + json.dumps([data["name"], data["format"]], ensure_ascii=False).encode() + ).hexdigest() + previous = PartnerProgram.objects.filter( + created_by=actor, creation_key=data["idempotency_key"] + ).first() + if previous: + if previous.creation_fingerprint != fingerprint: + raise WorkspaceConflict( + "creation_key_reused", + "Этот запрос уже создал другую программу. Обновите форму создания.", + ) + return previous, False + competitive = data["format"] != "noncompetitive" + program = PartnerProgram.objects.create( + name=data["name"], + tag="", + city="", + draft=True, + program_format=data["format"], + created_by=actor, + creation_key=data["idempotency_key"], + creation_fingerprint=fingerprint, + is_competitive=competitive, + solution_storage_mode="solution" if competitive else "legacy_project", + solution_template=data["format"] if competitive else None, + # Only a new program opts in. Existing switches and operator rollback stay intact. + solution_creation_enabled=competitive, + ) + program.managers.add(actor) + return program, True + + +def preview_program(pk, user, request): + from partner_programs.serializers import PartnerProgramForMemberSerializer + + program = managed_program(pk, user) + data = PartnerProgramForMemberSerializer( + program, context={"request": request, "user": user, "program_user_profile": None} + ).data + # Read-only participant presentation, never an enrollment or an authorization grant. + data.update( + preview=True, + draft=program.draft, + is_user_manager=False, + is_user_expert=False, + is_user_member=True, + current_application=None, + pending_expert_invitation_id=None, + ) + participant = { + "case_provided": program.fields.filter(name="case").exists(), + "case_name": None, + "stage": "none" if program.is_competitive else "not_applicable", + "submission_open": program.is_project_submission_open(), + } + participant[ + "application" + if program.solution_storage_mode == "solution" + else "participant_project" + ] = None + data["preview_widget"] = { + "program_id": program.pk, + "role": "participant", + "is_competitive": program.is_competitive, + "storage_mode": program.solution_storage_mode, + "participant": participant, + } + if program.solution_storage_mode == "solution": + from partner_programs.solution_api import context_data + + context = context_data(program, user, preview_participant=True) + data["solution_action_available"] = context["can_create"] + data["solution_action_blocked_reason"] = context["blocked_reason"] + return data + + +def _revision(program, config): + values = { + "program": { + name: getattr(program, name) for name in (*BASIC_FIELDS, *DATE_FIELDS) + }, + "updated_at": program.datetime_updated, + "registration": { + "revision": config.revision, + "draft": config.draft, + "external_status": config.external_status, + "external_url": config.external_url, + } + if config + else None, + } + return hashlib.sha256( + json.dumps(values, sort_keys=True, default=str).encode() + ).hexdigest() + + +def _registration_problem(config): + if config is None: + return "Выберите способ регистрации и сохраните анкету." + try: + schema = registration.clean_schema(config.draft, publishing=True) + except ValidationError as error: + detail = error.detail + return " ".join(map(str, detail)) if isinstance(detail, list) else str(detail) + if schema["mode"] == "external": + if config.external_status != "connected": + return "Дождитесь подключения внешней формы администратором." + try: + URLValidator(schemes=("http", "https"))(config.external_url) + except DjangoValidationError: + return "Администратору нужно проверить адрес подключенной формы." + return None + + +def _date_problem(program): + if any( + getattr(program, field) is None + for field in ( + "datetime_started", + "datetime_finished", + "datetime_registration_ends", + ) + ): + return "Укажите начало, окончание программы и окончание регистрации." + if program.datetime_finished <= program.datetime_started: + return "Окончание программы должно быть позже начала." + # Enrollment may continue after the program starts, as in the existing API. + # No new relative registration cutoff is introduced by this launch flow. + if program.datetime_registration_ends <= timezone.now(): + return "Окончание регистрации должно быть в будущем, чтобы открыть прием." + return None + + +def readiness_data(program, config=None): + if config is None: + config = registration.config_for(program) + page_problem = ( + None + if program.name.strip() + and unescape(strip_tags(program.description or "")).strip() + else "Заполните название и описание программы." + ) + checks = [] + blockers = [] + for code, title, section, problem, ready_detail in ( + ( + "page", + "Страница программы", + "basic", + page_problem, + "Название и описание заполнены. Обложку можно добавить позже.", + ), + ( + "dates", + "Сроки", + "dates", + _date_problem(program), + "Сроки программы и регистрации сохранены.", + ), + ( + "registration", + "Регистрация", + "registration", + _registration_problem(config), + "Способ регистрации и анкета готовы.", + ), + ): + checks.append( + dict( + code=code, + title=title, + section=section, + ready=problem is None, + detail=problem or ready_detail, + ) + ) + if problem: + blockers.append(dict(code=code, section=section, detail=problem)) + return { + "program": summary(program), + "revision": _revision(program, config), + "checks": checks, + "blockers": blockers, + "warnings": [ + "Форму решения, критерии и экспертов можно настроить после публикации." + ] + if program.is_competitive + else [], + "can_publish": program.draft and not blockers, + "links": { + "program": f"{registration.frontend_url()}/office/program/{program.pk}", + "registration": registration.public_link(program) + if not program.draft + else None, + }, + } + + +def readiness(pk, user): + return readiness_data(managed_program(pk, user)) + + +@transaction.atomic +def publish_program(pk, user, revision): + program = managed_program(pk, user, lock=True) + config = ( + ProgramRegistration.objects.select_for_update().filter(program=program).first() + ) + program._native_registration_config = config + state = readiness_data(program, config) + if not program.draft: + # Retry after a lost response never publishes a second questionnaire version. + return state + if state["revision"] != revision: + raise WorkspaceConflict( + "stale_program_launch", + "Настройки изменились. Обновите данные и повторите проверку.", + ) + if state["blockers"]: + raise ValidationError( + { + "code": "program_not_ready", + "detail": "Завершите обязательные настройки программы.", + "blockers": state["blockers"], + } + ) + schema = registration.clean_schema(config.draft, publishing=True) + config.published = copy.deepcopy(schema) + config.published_at = timezone.now() + config.published_version += 1 + config.revision += 1 + config.save( + update_fields=["published", "published_at", "published_version", "revision"] + ) + program.draft = False + program.save(update_fields=["draft", "datetime_updated"]) + return readiness_data(program, config) diff --git a/partner_programs/services/project_fields.py b/partner_programs/services/project_fields.py index 4eb7f1c0..402ab662 100644 --- a/partner_programs/services/project_fields.py +++ b/partner_programs/services/project_fields.py @@ -51,9 +51,9 @@ def resolve_legacy_program_link(project_id, user): return links[0] -def program_link_fields(link_id, user): +def program_link_fields(link_id, user, *, link=None): """Restricted read, current-program roles only; definitions/values are bulk loaded.""" - link = get_program_link(link_id) + link = link if link is not None and link.pk == link_id else get_program_link(link_id) if not has_project_read_involvement( user, link.project, program_id=link.partner_program_id ): diff --git a/partner_programs/services/registration.py b/partner_programs/services/registration.py index 58f422f4..3acb1718 100644 --- a/partner_programs/services/registration.py +++ b/partner_programs/services/registration.py @@ -43,6 +43,9 @@ def register_user_to_program( ) -> PartnerProgramUserProfile: from partner_programs.services.native_registration import config_for + if program.draft: + raise ProgramRegistrationError("Регистрация на программу еще не открыта.") + config = config_for(program) if config and config.published and config.published["mode"] == "native": raise ProgramRegistrationError( @@ -71,6 +74,9 @@ def create_user_and_register_to_program( ) -> PartnerProgramUserProfile: from partner_programs.services.native_registration import config_for + if program.draft: + raise ProgramRegistrationError("Регистрация на программу еще не открыта.") + config = config_for(program) if ( config diff --git a/partner_programs/solution_api.py b/partner_programs/solution_api.py index 55ac4b55..346e564a 100644 --- a/partner_programs/solution_api.py +++ b/partner_programs/solution_api.py @@ -65,11 +65,14 @@ def current_application(program, user): return None -def context_data(program, user): - application = current_application(program, user) - registered = PartnerProgramUserProfile.objects.filter( - partner_program=program, user=user - ).exists() +def context_data(program, user, *, preview_participant=False): + application = None if preview_participant else current_application(program, user) + registered = ( + preview_participant + or PartnerProgramUserProfile.objects.filter( + partner_program=program, user=user + ).exists() + ) reason = None if application: reason = "already_has_application" diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py new file mode 100644 index 00000000..8fbcf5cb --- /dev/null +++ b/partner_programs/tests/test_program_launch.py @@ -0,0 +1,473 @@ +from uuid import uuid4 +from unittest.mock import patch + +from django.contrib.auth.models import Permission +from django.test import TestCase, TransactionTestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from core.models import View +from partner_programs.models import ( + PartnerProgram, + PartnerProgramUserProfile, + ProgramRegistration, +) +from partner_programs.services.program_launch import publish_program +from partner_programs.tests.helpers import create_partner_program, create_user + + +class ProgramLaunchTestCase(TestCase): + def setUp(self): + self.client = APIClient() + self.creator = create_user(prefix="creator", is_active=True) + self.permission = Permission.objects.get( + content_type__app_label="partner_programs", codename="add_partnerprogram" + ) + self.creator.user_permissions.add(self.permission) + self.client.force_authenticate(self.creator) + + def create(self, **changes): + data = dict( + name="Новая программа", + format="case_championship", + idempotency_key=str(uuid4()), + ) + data.update(changes) + return self.client.post("/programs/drafts/", data, format="json") + + +class ProgramCreationTests(ProgramLaunchTestCase): + def test_all_formats_start_private_without_fake_dates_and_enrollment(self): + for kind in ("case_championship", "hackathon", "noncompetitive"): + with self.subTest(kind=kind): + response = self.create(format=kind) + self.assertEqual(response.status_code, 201, response.data) + program = PartnerProgram.objects.get(pk=response.data["id"]) + self.assertTrue(program.draft) + self.assertTrue(program.managers.filter(pk=self.creator.pk).exists()) + self.assertFalse(program.users.exists()) + self.assertIsNone(program.datetime_started) + self.assertIsNone(program.datetime_finished) + self.assertIsNone(program.datetime_registration_ends) + self.assertEqual(program.is_competitive, kind != "noncompetitive") + self.assertEqual( + program.solution_creation_enabled, kind != "noncompetitive" + ) + + def test_staff_and_existing_manager_need_explicit_creation_permission(self): + for staff in (False, True): + user = create_user(is_staff=staff) + create_partner_program().managers.add(user) + self.client.force_authenticate(user) + self.assertEqual(self.create().status_code, 403) + self.assertEqual( + self.client.get("/programs/capabilities/").data["can_create_program"], + False, + ) + self.assertFalse(PartnerProgram.objects.filter(created_by__isnull=False).exists()) + + def test_anonymous_cannot_create(self): + self.client.force_authenticate(None) + self.assertEqual(self.create().status_code, 401) + + def test_repeat_is_idempotent_even_after_name_is_edited(self): + key = str(uuid4()) + response = self.create(idempotency_key=key) + program = PartnerProgram.objects.get(pk=response.data["id"]) + program.name = "Новое название" + program.save(update_fields=["name", "datetime_updated"]) + retry = self.create(idempotency_key=key) + self.assertEqual(retry.status_code, 200) + self.assertEqual(retry.data["id"], program.pk) + self.assertEqual( + PartnerProgram.objects.filter(created_by=self.creator).count(), 1 + ) + self.assertEqual( + self.create(idempotency_key=key, format="hackathon").status_code, 409 + ) + + def test_scope_pagination_search_and_revoked_creation_right(self): + mine = self.create().data["id"] + other = create_partner_program(name="Чужая программа", draft=True) + self.creator.user_permissions.remove(self.permission) + self.creator = type(self.creator).objects.get(pk=self.creator.pk) + self.client.force_authenticate(self.creator) + self.assertEqual(self.create().status_code, 403) + response = self.client.get("/programs/managed/?limit=1&offset=0&search=Новая") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual([r["id"] for r in response.data["results"]], [mine]) + self.assertNotEqual(mine, other.pk) + self.assertEqual( + self.client.get(f"/programs/{mine}/management/").status_code, 200 + ) + + def test_invalid_requests_do_not_leave_empty_programs(self): + for data in ( + {"name": " "}, + {"format": "other"}, + {"idempotency_key": "bad"}, + {"draft": False}, + ): + self.assertEqual(self.create(**data).status_code, 400) + self.assertFalse(PartnerProgram.objects.exists()) + + def test_legacy_catalog_post_cannot_bypass_permission(self): + user = create_user() + self.client.force_authenticate(user) + self.assertEqual( + self.client.post("/programs/", {"name": "Обход"}, format="json").status_code, + 403, + ) + self.assertFalse(PartnerProgram.objects.exists()) + + def test_draft_is_hidden_across_public_endpoints(self): + pk = self.create().data["id"] + stranger = create_user() + for user in (None, stranger): + self.client.force_authenticate(user) + for suffix in ( + "", + "schema/", + "news/", + "filters/", + "projects/", + "registration/", + "solution-context/", + "solution-form/", + "management/", + "management/preview/", + ): + with self.subTest(user=user, suffix=suffix): + self.assertEqual( + self.client.get(f"/programs/{pk}/{suffix}").status_code, 404 + ) + for suffix in ( + "register/", + "register_new/", + "set_viewed/", + "set_liked/", + "projects/apply/", + "solutions/", + ): + with self.subTest(user=user, suffix=suffix): + self.assertEqual( + self.client.post( + f"/programs/{pk}/{suffix}", {}, format="json" + ).status_code, + 404, + ) + ids = [row["id"] for row in self.client.get("/programs/").data["results"]] + self.assertNotIn(pk, ids) + + def test_preview_has_no_enrollment_or_view_side_effects_and_disallows_mutations(self): + pk = self.create().data["id"] + before = View.objects.count() + response = self.client.get(f"/programs/{pk}/management/preview/") + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["preview"]) + self.assertEqual(response.data["name"], "Новая программа") + self.assertFalse( + PartnerProgramUserProfile.objects.filter(partner_program_id=pk).exists() + ) + self.assertEqual(View.objects.count(), before) + self.assertEqual( + self.client.post( + f"/programs/{pk}/set_viewed/", {}, format="json" + ).status_code, + 404, + ) + + def test_project_creation_cannot_bind_to_an_unpublished_program(self): + pk = self.create().data["id"] + response = self.client.post( + "/projects/", {"name": "Обход", "partner_program_id": pk}, format="json" + ) + self.assertEqual(response.status_code, 404, response.data) + + def test_draft_reads_and_hidden_responses_are_not_cacheable(self): + pk = self.create().data["id"] + response = self.client.get(f"/programs/{pk}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.client.force_authenticate(None) + response = self.client.get(f"/programs/{pk}/") + self.assertEqual(response.status_code, 404) + self.assertEqual(response["Cache-Control"], "private, no-store") + + +class ProgramPublicationTests(ProgramLaunchTestCase): + def setUp(self): + super().setUp() + self.program = PartnerProgram.objects.get(pk=self.create().data["id"]) + self.url = f"/programs/{self.program.pk}/management/" + + def configure(self, mode="native"): + now = timezone.now() + self.program.description = "Описание программы" + self.program.datetime_started = now + timezone.timedelta(days=10) + self.program.datetime_finished = now + timezone.timedelta(days=30) + self.program.datetime_registration_ends = now + timezone.timedelta(days=5) + self.program.save() + return ProgramRegistration.objects.create( + program=self.program, draft={"mode": mode, "fields": []} + ) + + def state(self): + response = self.client.get(self.url + "launch/") + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def publish(self, revision=None): + return self.client.post( + self.url + "publish/", + {"revision": revision or self.state()["revision"]}, + format="json", + ) + + def test_blockers_explain_missing_settings_and_publish_does_not_partially_commit( + self, + ): + state = self.state() + self.assertEqual( + {r["code"] for r in state["blockers"]}, {"page", "dates", "registration"} + ) + self.assertFalse(state["can_publish"]) + self.assertEqual(self.publish().status_code, 400) + self.program.refresh_from_db() + self.assertTrue(self.program.draft) + self.assertFalse( + ProgramRegistration.objects.filter(program=self.program).exists() + ) + + def test_first_publication_opens_native_registration_without_solution_setup(self): + config = self.configure() + self.assertTrue(self.state()["can_publish"]) + response = self.publish() + self.assertEqual(response.status_code, 200, response.data) + self.program.refresh_from_db() + config.refresh_from_db() + self.assertFalse(self.program.draft) + self.assertEqual(config.published, {"mode": "native", "fields": []}) + self.assertEqual(config.published_version, 1) + self.assertEqual( + self.client.get(f"/programs/{self.program.pk}/registration/").status_code, 200 + ) + self.assertIn(str(self.program.pk), response.data["links"]["registration"]) + self.assertFalse(self.program.fields.exists()) + + def test_registration_preparation_does_not_publish_anything_while_program_is_draft( + self, + ): + response = self.client.post( + self.url + "registration/publish/", + {"revision": 0, "draft": {"mode": "native", "fields": []}}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["program_draft"]) + self.assertIsNone(response.data["published"]) + self.assertIsNone(response.data["link"]) + self.program.refresh_from_db() + self.assertTrue(self.program.draft) + + def test_external_registration_waits_for_real_connection(self): + config = self.configure("external") + self.assertEqual([r["code"] for r in self.state()["blockers"]], ["registration"]) + self.assertEqual(self.publish().status_code, 400) + config.external_status = "connected" + config.external_url = "https://event.example.test/register" + config.save() + response = self.publish() + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["links"]["registration"], config.external_url) + + def test_stale_settings_and_questionnaire_are_detected(self): + config = self.configure() + for change in ("program", "registration"): + revision = self.state()["revision"] + if change == "program": + self.program.description += " Изменение" + self.program.save() + else: + config.revision += 1 + config.save() + response = self.publish(revision) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "stale_program_launch") + self.program.refresh_from_db() + self.assertTrue(self.program.draft) + + def test_retry_after_success_does_not_publish_a_second_form_version(self): + config = self.configure() + revision = self.state()["revision"] + self.assertEqual(self.publish(revision).status_code, 200) + self.assertEqual(self.publish(revision).status_code, 200) + config.refresh_from_db() + self.assertEqual(config.published_version, 1) + + def test_failure_between_registration_and_program_save_rolls_back_both(self): + config = self.configure() + revision = self.state()["revision"] + with patch.object( + PartnerProgram, "save", side_effect=RuntimeError("simulated failure") + ): + with self.assertRaises(RuntimeError): + publish_program(self.program.pk, self.creator, revision) + config.refresh_from_db() + self.program.refresh_from_db() + self.assertIsNone(config.published) + self.assertEqual(config.published_version, 0) + self.assertTrue(self.program.draft) + + def test_noncompetitive_program_requires_no_competition_configuration(self): + self.program = PartnerProgram.objects.get( + pk=self.create(format="noncompetitive").data["id"] + ) + self.url = f"/programs/{self.program.pk}/management/" + self.configure() + self.assertEqual(self.publish().status_code, 200) + self.program.refresh_from_db() + self.assertFalse(self.program.is_competitive) + + def test_invalid_registration_schema_and_expired_registration_block_launch(self): + config = self.configure() + config.draft = {"mode": "other", "fields": []} + config.save() + self.program.datetime_registration_ends = timezone.now() - timezone.timedelta( + days=1 + ) + self.program.save() + self.assertEqual( + {r["code"] for r in self.state()["blockers"]}, {"dates", "registration"} + ) + self.assertEqual(self.publish().status_code, 400) + + def test_program_end_must_follow_start_but_registration_can_continue_after_start( + self, + ): + self.configure() + self.program.datetime_registration_ends = ( + self.program.datetime_started + timezone.timedelta(days=1) + ) + self.program.save() + self.assertTrue(self.state()["can_publish"]) + self.program.datetime_finished = self.program.datetime_started + self.program.save() + self.assertEqual([r["code"] for r in self.state()["blockers"]], ["dates"]) + self.assertEqual(self.publish().status_code, 400) + + def test_published_program_keeps_existing_nonnullable_dates_contract(self): + self.configure() + self.publish() + self.program.refresh_from_db() + response = self.client.patch( + self.url + "settings/dates/", + { + "expected_updated_at": self.program.datetime_updated.isoformat(), + "datetime_started": None, + }, + format="json", + ) + self.assertEqual(response.status_code, 400, response.data) + + def test_later_solution_configuration_needs_no_technical_rollout(self): + self.configure() + self.publish() + participant = create_user(is_active=True) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, user=participant, partner_program_data={} + ) + self.client.force_authenticate(participant) + url = f"/programs/{self.program.pk}/solution-context/" + self.assertEqual( + self.client.get(url).data["blocked_reason"], "form_not_configured" + ) + detail_url = f"/programs/{self.program.pk}/" + detail = self.client.get(detail_url).data + self.assertFalse(detail["solution_action_available"]) + self.assertEqual(detail["solution_action_blocked_reason"], "form_not_configured") + self.program.fields.create( + name="case", + label="Кейс", + field_type="select", + purpose="case", + is_required=True, + show_filter=True, + options="Кейс 1", + solution_options=["Кейс 1"], + ) + self.assertTrue(self.client.get(url).data["can_create"]) + self.assertTrue(self.client.get(detail_url).data["solution_action_available"]) + self.program.solution_creation_enabled = False + self.program.save(update_fields=["solution_creation_enabled"]) + self.assertEqual( + self.client.get(url).data["blocked_reason"], "solution_creation_disabled" + ) + + +class ProgramLaunchConcurrencyTests(TransactionTestCase): + def setUp(self): + self.creator = create_user(is_active=True) + self.creator.user_permissions.add( + Permission.objects.get( + content_type__app_label="partner_programs", codename="add_partnerprogram" + ) + ) + + def parallel_posts(self, url, data): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + from django.db import connections + + barrier = Barrier(2) + + def post(_): + try: + client = APIClient() + client.force_authenticate( + type(self.creator).objects.get(pk=self.creator.pk) + ) + barrier.wait(timeout=10) + response = client.post(url, data, format="json") + return response.status_code, response.data + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + return list(executor.map(post, range(2))) + + def test_concurrent_create_has_one_program_and_manager_assignment(self): + results = self.parallel_posts( + "/programs/drafts/", + dict(name="Concurrent", format="hackathon", idempotency_key=str(uuid4())), + ) + self.assertEqual(sorted(code for code, _ in results), [200, 201], results) + self.assertEqual(len({data["id"] for _, data in results}), 1) + self.assertEqual(PartnerProgram.objects.count(), 1) + self.assertEqual(PartnerProgram.objects.get().managers.count(), 1) + + def test_concurrent_publication_publishes_registration_once(self): + from partner_programs.services.program_launch import create_program, readiness + + program, _ = create_program( + self.creator, + dict(name="Concurrent", format="hackathon", idempotency_key=uuid4()), + ) + now = timezone.now() + program.description = "Description" + program.datetime_started = now + timezone.timedelta(days=10) + program.datetime_finished = now + timezone.timedelta(days=30) + program.datetime_registration_ends = now + timezone.timedelta(days=5) + program.save() + config = ProgramRegistration.objects.create( + program=program, draft={"mode": "native", "fields": []} + ) + revision = readiness(program.pk, self.creator)["revision"] + results = self.parallel_posts( + f"/programs/{program.pk}/management/publish/", {"revision": revision} + ) + self.assertEqual([code for code, _ in results], [200, 200], results) + config.refresh_from_db() + program.refresh_from_db() + self.assertFalse(program.draft) + self.assertEqual(config.published_version, 1) diff --git a/partner_programs/tests/test_solution_creation_switch.py b/partner_programs/tests/test_solution_creation_switch.py index 68da9e89..6058e89a 100644 --- a/partner_programs/tests/test_solution_creation_switch.py +++ b/partner_programs/tests/test_solution_creation_switch.py @@ -462,27 +462,28 @@ def test_existing_programs_and_solution_data_survive_disabled_default(self): self.ready() self.assertEqual(self.submit().status_code, 200) create_partner_program() - columns = [ - f.attname - for f in PartnerProgram._meta.fields - if f.name != "solution_creation_enabled" - ] - before_programs = list(PartnerProgram.objects.order_by("pk").values(*columns)) executor = MigrationExecutor(connection) latest = executor.loader.graph.leaf_nodes() before = [("partner_programs", "0033_solution_publication")] after = [("partner_programs", "0034_solution_creation_switch")] historical = executor.loader.project_state(after).apps + historical_program = historical.get_model("partner_programs", "PartnerProgram") + columns = [ + f.attname + for f in historical_program._meta.fields + if f.name != "solution_creation_enabled" + ] + before_programs = list(PartnerProgram.objects.order_by("pk").values(*columns)) before_data = domain_state(historical) try: executor.migrate(before) executor = MigrationExecutor(connection) executor.migrate(after) self.assertFalse( - PartnerProgram.objects.filter(solution_creation_enabled=True).exists() + historical_program.objects.filter(solution_creation_enabled=True).exists() ) self.assertEqual( - list(PartnerProgram.objects.order_by("pk").values(*columns)), + list(historical_program.objects.order_by("pk").values(*columns)), before_programs, ) self.assertEqual(domain_state(historical), before_data) diff --git a/partner_programs/tests/test_solution_dev_migrations.py b/partner_programs/tests/test_solution_dev_migrations.py index 6b5f990b..580771d8 100644 --- a/partner_programs/tests/test_solution_dev_migrations.py +++ b/partner_programs/tests/test_solution_dev_migrations.py @@ -33,7 +33,9 @@ class SolutionDevGraphTests(TestCase): def test_single_leaf_empty_merge_and_unchanged_upstream_migration(self): loader = MigrationLoader(None) self.assertEqual(loader.detect_conflicts(), {}) - self.assertEqual(loader.graph.leaf_nodes("partner_programs"), [MERGED]) + leaves = loader.graph.leaf_nodes("partner_programs") + self.assertEqual(len(leaves), 1) + self.assertIn(MERGED, loader.graph.forwards_plan(leaves[0])) migration = loader.disk_migrations[MERGED] self.assertCountEqual(migration.dependencies, [CERTIFICATE, SOLUTION]) self.assertEqual(migration.operations, []) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 223673a2..9da89ba5 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -144,405 +144,578 @@ PublicCertificateDownloadView, ) +from partner_programs.permissions import program_view +from partner_programs.program_launch_views import ( + ProgramCapabilitiesView, + ProgramDraftCreateView, + ManagedProgramsView, + ProgramPreviewView, + ProgramReadinessView, + ProgramPublishView, +) + app_name = "partner_programs" urlpatterns = [ - path("/published-solutions/", PublishedSolutionListView.as_view()), + path( + "capabilities/", + program_view(ProgramCapabilitiesView), + ), + path( + "drafts/", + program_view(ProgramDraftCreateView), + ), + path( + "managed/", + program_view(ManagedProgramsView), + ), + path( + "/management/preview/", + program_view(ProgramPreviewView), + ), + path("/management/launch/", program_view(ProgramReadinessView)), + path("/management/publish/", program_view(ProgramPublishView)), + path( + "/published-solutions/", + program_view(PublishedSolutionListView), + ), path( "/published-solutions//", - PublishedSolutionDetailView.as_view(), + program_view(PublishedSolutionDetailView), ), path( "/management/solution-analytics/attention/solutions-awaiting-evaluation/reminder-preview/", - SolutionExpertReminderView.as_view(), + program_view(SolutionExpertReminderView), ), path( "/management/solution-analytics/attention/solutions-awaiting-evaluation/reminders/", - SolutionExpertReminderView.as_view(reminder_action="confirm"), + program_view(SolutionExpertReminderView, reminder_action="confirm"), ), path( - "/management/solution-analytics/", SolutionManagerAnalyticsView.as_view() + "/management/solution-analytics/", + program_view(SolutionManagerAnalyticsView), ), path( "/management/solution-analytics/assignments/", - SolutionManagerAssignmentsView.as_view(), + program_view(SolutionManagerAssignmentsView), ), path( "/management/solution-analytics/assignments//", - SolutionManagerAssignmentView.as_view(), + program_view(SolutionManagerAssignmentView), ), path( "/management/solution-analytics/solutions/", - SolutionManagerSolutionsView.as_view(), + program_view(SolutionManagerSolutionsView), ), path( "/management/solution-analytics/export/", - SolutionManagerExportView.as_view(), + program_view(SolutionManagerExportView), ), path( "/management/solution-cases/", - SolutionCaseAnalyticsView.as_view(), + program_view(SolutionCaseAnalyticsView), name="solution-case-analytics", ), path( "/management/solution-evaluation/allocation/", - SolutionAllocationView.as_view(), + program_view(SolutionAllocationView), ), path( "/management/solution-evaluation/allocation/apply/", - SolutionAllocationApplyView.as_view(), + program_view(SolutionAllocationApplyView), + ), + path( + "/management/solution-evaluation/quota/", + program_view(SolutionQuotaView), + ), + path( + "/management/solution-results/", + program_view(SolutionResultsView), ), - path("/management/solution-evaluation/quota/", SolutionQuotaView.as_view()), - path("/management/solution-results/", SolutionResultsView.as_view()), path( "/management/solution-results/export/", - SolutionResultsExportView.as_view(), + program_view(SolutionResultsExportView), ), - path("/solution-evaluation/", SolutionRatingsView.as_view()), path( - "/solutions//evaluation/", SolutionRatingView.as_view() + "/solution-evaluation/", + program_view(SolutionRatingsView), + ), + path( + "/solutions//evaluation/", + program_view(SolutionRatingView), ), path( "/management/solution-evaluation/experts//assignments/", - SolutionExpertAssignmentsView.as_view(), + program_view(SolutionExpertAssignmentsView), + ), + path( + "/solution-form/", + program_view(SolutionFormView), + ), + path( + "/management/solution-form/", + program_view(ManagedSolutionFormView), + ), + path( + "/solution-context/", + program_view(SolutionContextView), + ), + path( + "/solutions/", + program_view(SolutionListCreateView), + ), + path( + "/solutions//", + program_view(SolutionDetailView), + ), + path( + "/solutions//submit/", + program_view(SolutionSubmitView), ), - path("/solution-form/", SolutionFormView.as_view()), - path("/management/solution-form/", ManagedSolutionFormView.as_view()), - path("/solution-context/", SolutionContextView.as_view()), - path("/solutions/", SolutionListCreateView.as_view()), - path("/solutions//", SolutionDetailView.as_view()), - path("/solutions//submit/", SolutionSubmitView.as_view()), path( "/solutions//members//", - SolutionMemberView.as_view(), + program_view(SolutionMemberView), + ), + path( + "/solutions//leave/", + program_view(SolutionLeaveView), + ), + path( + "/solutions//captain/", + program_view(SolutionCaptainView), ), - path("/solutions//leave/", SolutionLeaveView.as_view()), - path("/solutions//captain/", SolutionCaptainView.as_view()), path( "/solutions//invitations/", - SolutionInvitationsView.as_view(), + program_view(SolutionInvitationsView), + ), + path( + "/management/certificates/", + program_view(CertificateSettingsView), + ), + path( + "/management/certificates/assets/", + program_view(CertificateAssetView), ), - path("/management/certificates/", CertificateSettingsView.as_view()), - path("/management/certificates/assets/", CertificateAssetView.as_view()), path( "/management/certificates/assets//", - CertificateAssetView.as_view(), + program_view(CertificateAssetView), + ), + path( + "/management/certificates/preview/", + program_view(CertificatePreviewView), + ), + path( + "/management/certificates/test/", + program_view(CertificateTestView), + ), + path( + "/management/certificates/activate/", + program_view(CertificateActivateView), ), - path("/management/certificates/preview/", CertificatePreviewView.as_view()), - path("/management/certificates/test/", CertificateTestView.as_view()), - path("/management/certificates/activate/", CertificateActivateView.as_view()), path( "/management/certificates/queue//", - CertificateQueueView.as_view(), + program_view(CertificateQueueView), + ), + path( + "/management/certificates/deliveries/", + program_view(CertificateJournalView), ), path( - "/management/certificates/deliveries/", CertificateJournalView.as_view() + "/management/certificates/retry/", + program_view(CertificateRetryView), ), - path("/management/certificates/retry/", CertificateRetryView.as_view()), path( - "/management/certificates/selection/", CertificateSelectionView.as_view() + "/management/certificates/selection/", + program_view(CertificateSelectionView), ), path( "/management/certificates/deliveries//pdf/", - CertificateDownloadView.as_view(), + program_view(CertificateDownloadView), + ), + path( + "/management/certificates/archive/", + program_view(CertificateArchiveView), + ), + path( + "/certificates/me/", + program_view(MyCertificatesView), ), - path("/management/certificates/archive/", CertificateArchiveView.as_view()), - path("/certificates/me/", MyCertificatesView.as_view()), path( "/certificates/download//", - PublicCertificateDownloadView.as_view(), + program_view(PublicCertificateDownloadView), ), path( "/management/solutions//case/preview/", - SolutionCaseChangeView.as_view(), + program_view(SolutionCaseChangeView), ), path( "/management/solutions//members/remove/preview/", - SolutionMemberRemovalView.as_view(), + program_view(SolutionMemberRemovalView), ), path( "/management/solutions//members/remove/apply/", - SolutionMemberRemovalView.as_view(apply_action=True), + program_view(SolutionMemberRemovalView, apply_action=True), ), path( "/management/solutions//history/", - SolutionHistoryView.as_view(), + program_view(SolutionHistoryView), ), path( "/management/solutions//history//", - SolutionHistoryView.as_view(), + program_view(SolutionHistoryView), ), path( "/management/solutions//case/apply/", - SolutionCaseChangeView.as_view(apply_action=True), + program_view(SolutionCaseChangeView, apply_action=True), ), path( "/management/solutions/actions/preview/", - SolutionActionView.as_view(), + program_view(SolutionActionView), ), path( "/management/registrations/remove/preview/", - SolutionRegistrationRemovalView.as_view(), + program_view(SolutionRegistrationRemovalView), ), path( "/management/registrations/remove/apply/", - SolutionRegistrationRemovalView.as_view(apply_action=True), + program_view(SolutionRegistrationRemovalView, apply_action=True), ), path( "/management/registrations/removals/", - SolutionRegistrationRemovalHistoryView.as_view(), + program_view(SolutionRegistrationRemovalHistoryView), ), path( "/management/registrations/removals//", - SolutionRegistrationRemovalHistoryView.as_view(), + program_view(SolutionRegistrationRemovalHistoryView), + ), + path( + "/management/solutions/archive/preview/", + program_view(SolutionArchiveView), ), - path("/management/solutions/archive/preview/", SolutionArchiveView.as_view()), path( "/management/solutions/archive/apply/", - SolutionArchiveView.as_view(apply_action=True), + program_view(SolutionArchiveView, apply_action=True), ), path( "/management/solutions/actions/apply/", - SolutionActionView.as_view(apply_action=True), + program_view(SolutionActionView, apply_action=True), ), path( "/management/projects/actions/preview/", - ManagementProjectActionPreviewView.as_view(), + program_view(ManagementProjectActionPreviewView), ), path( "/management/projects/actions/apply/", - ManagementProjectActionApplyView.as_view(), + program_view(ManagementProjectActionApplyView), + ), + path( + "/manager-overview/export/", + program_view(ProgramReportView), ), - path("/manager-overview/export/", ProgramReportView.as_view()), path( "/manager-overview/attention///", - AttentionActionView.as_view(), + program_view(AttentionActionView), + ), + path( + "/management/registration/", + program_view(RegistrationSettingsView), + ), + path( + "/management/registration/publish/", + program_view(RegistrationPublishView), + ), + path( + "/management/registration/import/", + program_view(RegistrationImportView), ), - 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(), + program_view(RegistrationConnectionView), + ), + path( + "/registration/", + program_view(PublicRegistrationView), + ), + path( + "/registration//", + program_view(PublicRegistrationActionView), + ), + path( + "organizer-invitations//", + program_view(PublicOrganizerInvitationView), ), - path("/registration/", PublicRegistrationView.as_view()), - path("/registration//", PublicRegistrationActionView.as_view()), - path("organizer-invitations//", PublicOrganizerInvitationView.as_view()), path( "organizer-invitations///", - PublicOrganizerInvitationActionView.as_view(), + program_view(PublicOrganizerInvitationActionView), + ), + path( + "/management/organizers/", + program_view(OrganizersView), ), - path("/management/organizers/", OrganizersView.as_view()), path( "/management/organizers//remove/", - OrganizerAccessView.as_view(), + program_view(OrganizerAccessView), ), path( "/management/organizer-invitations///", - OrganizerInvitationActionView.as_view(), + program_view(OrganizerInvitationActionView), ), path( "/management/results/", - ManagementResultsView.as_view(), + program_view(ManagementResultsView), name="management-results", ), path( "/management/results/export/", - ManagementResultsExportView.as_view(), + program_view(ManagementResultsExportView), name="management-results-export", ), - path("/management/experts/", ExpertsView.as_view()), - path("/management/experts/bulk/", ExpertsBulkView.as_view()), + path( + "/management/experts/", + program_view(ExpertsView), + ), + path( + "/management/experts/bulk/", + program_view(ExpertsBulkView), + ), path( "/management/experts//cases/", - ExpertCaseScopeView.as_view(), + program_view(ExpertCaseScopeView), + ), + path( + "/management/project-experts/", + program_view(ProjectExpertsView), ), - path("/management/project-experts/", ProjectExpertsView.as_view()), path( "/management/expert-invitations///", - InvitationActionView.as_view(), + program_view(InvitationActionView), ), path( "/management/experts//assignments/", - ExpertAssignmentsView.as_view(), + program_view(ExpertAssignmentsView), + ), + path( + "/management/expert-allocation/", + program_view(AllocationView), + ), + path( + "/management/expert-allocation/apply/", + program_view(AllocationApplyView), + ), + path( + "/management/evaluators/", + program_view(EvaluatorsView), ), - path("/management/expert-allocation/", AllocationView.as_view()), - path("/management/expert-allocation/apply/", AllocationApplyView.as_view()), - path("/management/evaluators/", EvaluatorsView.as_view()), path( "/management/criteria/", - ManagementCriteriaView.as_view(), + program_view(ManagementCriteriaView), name="management-criteria", ), path( "/management/project-form/", - ManagementProjectFormView.as_view(), + program_view(ManagementProjectFormView), name="management-project-form", ), - path("/management/", ManagementView.as_view(), name="management"), + path( + "/management/", + program_view(ManagementView), + name="management", + ), path( "/management/participants/selection/", - ManagementSelectionView.as_view(), + program_view(ManagementSelectionView), {"kind": "participants"}, ), path( "/management/participants/remove-preview/", - ManagementParticipantsRemovalPreviewView.as_view(), + program_view(ManagementParticipantsRemovalPreviewView), ), path( "/management/participants/remove/", - ManagementParticipantsRemovalView.as_view(), + program_view(ManagementParticipantsRemovalView), ), path( "/management/projects/selection/", - ManagementSelectionView.as_view(), + program_view(ManagementSelectionView), {"kind": "projects"}, ), path( "/management/participants/export/", - ManagementParticipantsExportView.as_view(), + program_view(ManagementParticipantsExportView), name="management-participants-export", ), path( "/management/participants/", - ManagementParticipantsView.as_view(), + program_view(ManagementParticipantsView), name="management-participants", ), path( "/management/projects/", - ManagementProjectsView.as_view(), + program_view(ManagementProjectsView), name="management-projects", ), path( "/management/projects/export/", - ManagementProjectsExportView.as_view(), + program_view(ManagementProjectsExportView), name="management-projects-export", ), path( "/management/projects//team/", - ManagementTeamView.as_view(), + program_view(ManagementTeamView), name="management-team", ), path( "/management/projects//evaluation/", - ManagementEvaluationView.as_view(), + program_view(ManagementEvaluationView), name="management-evaluation", ), path( "/management/settings//", - ManagementSettingsView.as_view(), + program_view(ManagementSettingsView), name="management-settings", ), path( "/management/materials/", - ManagementMaterialsView.as_view(), + program_view(ManagementMaterialsView), name="management-materials", ), path( "/management/materials//", - ManagementMaterialDetailView.as_view(), + program_view(ManagementMaterialDetailView), name="management-material", ), path( "/analytics-widget/", - ProgramRoleWidgetAPIView.as_view(), + program_view(ProgramRoleWidgetAPIView), name="analytics-widget", ), - path("", PartnerProgramList.as_view()), - path("/", PartnerProgramDetail.as_view()), + path( + "", + program_view(PartnerProgramList), + ), + path( + "/", + program_view(PartnerProgramDetail), + ), path( "/manager-overview/", - ProgramManagerAnalyticsAPIView.as_view(), + program_view(ProgramManagerAnalyticsAPIView), name="manager-overview", ), path( "/manager-overview/participants-without-visit/", - ProgramManagerParticipantsWithoutVisitAPIView.as_view(), + program_view(ProgramManagerParticipantsWithoutVisitAPIView), name="manager-overview-participants-without-visit", ), path( "/manager-overview/participants-without-onboarding/", - ProgramManagerParticipantsWithoutOnboardingAPIView.as_view(), + program_view(ProgramManagerParticipantsWithoutOnboardingAPIView), name="manager-overview-participants-without-onboarding", ), path( "/manager-overview/participants-without-team/", - ProgramManagerParticipantsWithoutTeamAPIView.as_view(), + program_view(ProgramManagerParticipantsWithoutTeamAPIView), name="manager-overview-participants-without-team", ), path( "/manager-overview/projects-awaiting-evaluation/", - ProgramManagerProjectsAwaitingEvaluationAPIView.as_view(), + program_view(ProgramManagerProjectsAwaitingEvaluationAPIView), name="manager-overview-projects-awaiting-evaluation", ), path( "/manager-overview/projects-not-submitted/", - ProgramManagerProjectsNotSubmittedAPIView.as_view(), + program_view(ProgramManagerProjectsNotSubmittedAPIView), name="manager-overview-projects-not-submitted", ), path( "/manager-overview/assignments/", - ProgramManagerAssignmentsAPIView.as_view(), + program_view(ProgramManagerAssignmentsAPIView), name="manager-overview-assignments", ), path( "/manager-overview/assignments//scores/", - ProgramManagerAssignmentScoresAPIView.as_view(), + program_view(ProgramManagerAssignmentScoresAPIView), name="manager-overview-assignment-scores", ), path( "/acknowledge-welcome/", - PartnerProgramWelcomeAcknowledgement.as_view(), + program_view(PartnerProgramWelcomeAcknowledgement), name="partner-program-acknowledge-welcome", ), path( "partner-program-projects//submit/", - PartnerProgramProjectSubmitView.as_view(), + program_view(PartnerProgramProjectSubmitView), name="partner-program-project-submit", ), path( "partner-program-projects//fields/", - PartnerProgramProjectFieldsView.as_view(), + program_view(PartnerProgramProjectFieldsView), name="partner-program-project-fields", ), - path("/schema/", PartnerProgramDataSchema.as_view()), - path("/register/", PartnerProgramRegister.as_view()), - path("/register_new/", PartnerProgramCreateUserAndRegister.as_view()), - path("/set_liked/", PartnerProgramSetLiked.as_view()), - path("/set_viewed/", PartnerProgramSetViewed.as_view()), - path("/news/", NewsList.as_view()), - path("/news//", NewsDetail.as_view()), + path( + "/schema/", + program_view(PartnerProgramDataSchema), + ), + path( + "/register/", + program_view(PartnerProgramRegister), + ), + path( + "/register_new/", + program_view(PartnerProgramCreateUserAndRegister), + ), + path( + "/set_liked/", + program_view(PartnerProgramSetLiked), + ), + path( + "/set_viewed/", + program_view(PartnerProgramSetViewed), + ), + path( + "/news/", + program_view(NewsList), + ), + path( + "/news//", + program_view(NewsDetail), + ), path( "/news//set_viewed/", - NewsDetailSetViewed.as_view(), + program_view(NewsDetailSetViewed), + ), + path( + "/news//set_liked/", + program_view(NewsDetailSetLiked), ), path( - "/news//set_liked/", NewsDetailSetLiked.as_view() + "/filters/", + program_view(ProgramFiltersAPIView), + name="program-filters", ), - path("/filters/", ProgramFiltersAPIView.as_view(), name="program-filters"), path( "/projects/filter/", - ProgramProjectFilterAPIView.as_view(), + program_view(ProgramProjectFilterAPIView), name="program-projects-filter", ), path( "/projects/", - PartnerProgramProjectsAPIView.as_view(), + program_view(PartnerProgramProjectsAPIView), name="partner-program-projects", ), path( "/projects/apply/", - PartnerProgramProjectApplyView.as_view(), + program_view(PartnerProgramProjectApplyView), name="partner-program-project-apply", ), path( "/export-projects/", - PartnerProgramExportProjectsAPIView.as_view(), + program_view(PartnerProgramExportProjectsAPIView), name="partner-program-export-projects", ), path( "/export-rates/", - PartnerProgramExportRatesAPIView.as_view(), + program_view(PartnerProgramExportRatesAPIView), name="partner-program-export-rates", ), ] diff --git a/partner_programs/views.py b/partner_programs/views.py index 7428b2dd..68fa19d4 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -115,6 +115,21 @@ class PartnerProgramList(generics.ListCreateAPIView): permission_classes = [permissions.IsAuthenticatedOrReadOnly] pagination_class = PartnerProgramPagination + def get_permissions(self): + from partner_programs.permissions import CanCreateProgram + + checks = super().get_permissions() + return [*checks, CanCreateProgram()] if self.request.method == "POST" else checks + + def perform_create(self, serializer): + from django.db import transaction + from partner_programs.permissions import require_create_program + + require_create_program(self.request.user) + with transaction.atomic(): + program = serializer.save(created_by=self.request.user) + program.managers.add(self.request.user) + def get_queryset(self): base_qs = super().get_queryset() participating_flag = self.request.query_params.get("participating") @@ -188,6 +203,22 @@ def get(self, request, *args, **kwargs): data = serializer.data data["is_user_member"] = is_user_member data["current_application"] = current_application + if ( + program.program_format + and program.solution_storage_mode == "solution" + and is_user_member + ): + from partner_programs.solution_api import context_data + + solution_context = context_data(program, request.user) + available = ( + solution_context["can_create"] + or solution_context["current_application"] is not None + ) + data["solution_action_available"] = available + data["solution_action_blocked_reason"] = ( + None if available else solution_context["blocked_reason"] + ) data["is_user_expert"] = bool( request.user.is_authenticated and program.experts.filter(user_id=request.user.pk).exists() @@ -389,7 +420,11 @@ class PartnerProgramProjectFieldsView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk, *args, **kwargs): - return Response(program_link_fields(pk, request.user)) + return Response( + program_link_fields( + pk, request.user, link=getattr(self, "visibility_program_link", None) + ) + ) @swagger_auto_schema(request_body=PartnerProgramFieldValueUpdateSerializer(many=True)) def put(self, request, pk, *args, **kwargs): @@ -423,7 +458,9 @@ class ProgramManagerAnalyticsAccessAPIView(APIView): permission_classes = [IsAuthenticated] def get_program(self, request, pk): - program = get_object_or_404(PartnerProgram, pk=pk) + program = getattr(self, "visibility_program", None) + if program is None: + program = get_object_or_404(PartnerProgram, pk=pk) if not can_manage_program(request.user, program): raise PermissionDenied("Недостаточно прав.") return program diff --git a/projects/permissions.py b/projects/permissions.py index 47197688..c42755d5 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -2,7 +2,7 @@ from django.utils import timezone from django.db.models import Q -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import SAFE_METHODS, BasePermission from partner_programs.models import ( @@ -187,7 +187,10 @@ def has_object_permission(self, request, view, obj) -> bool: ) moscow_time: datetime = timezone.localtime(timezone.now()) - if program_profile: + if ( + program_profile + and program_profile.partner_program.datetime_finished is not None + ): date_from_end_program: timedelta = ( moscow_time - program_profile.partner_program.datetime_finished ) @@ -277,6 +280,9 @@ def has_permission(self, request, view): except PartnerProgram.DoesNotExist: raise ValidationError({"partner_program_id": "Программа не найдена."}) + if program.draft: + raise NotFound() + submission_deadline = program.get_project_submission_deadline() if submission_deadline and submission_deadline < timezone.now(): raise ValidationError( diff --git a/projects/team_policy.py b/projects/team_policy.py index 0396490a..c5e15db8 100644 --- a/projects/team_policy.py +++ b/projects/team_policy.py @@ -111,7 +111,11 @@ def reserved_user_ids(project): def has_active_exclusivity(program): - return program.is_competitive and program.datetime_finished > timezone.now() + return ( + program.is_competitive + and program.datetime_finished is not None + and program.datetime_finished > timezone.now() + ) def conflicting_team_users(program, *, project_id, user_ids, solution_id=None): diff --git a/projects/team_service.py b/projects/team_service.py index d0d5786f..afab1e37 100644 --- a/projects/team_service.py +++ b/projects/team_service.py @@ -325,6 +325,7 @@ def bind_project_to_program(*, project_id, program_id, actor, program_link_id=No if program: if ( not program.is_project_submission_open() + or program.datetime_finished is None or program.datetime_finished < timezone.now() ): raise TeamError( From 83b7ec3674f1084b87a28ce8e2fff90bd7469af6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 01:33:19 +0300 Subject: [PATCH 2/7] docs(programs): record launch contract acceptance and rollout --- .../contracts/program-creation-publication.md | 73 +++++++++ .../program-creation-publication.md | 141 ++++++++++++++++++ partner_programs/tests/test_program_launch.py | 2 + 3 files changed, 216 insertions(+) create mode 100644 docs/contracts/program-creation-publication.md create mode 100644 docs/feature-specs/program-creation-publication.md diff --git a/docs/contracts/program-creation-publication.md b/docs/contracts/program-creation-publication.md new file mode 100644 index 00000000..e1868452 --- /dev/null +++ b/docs/contracts/program-creation-publication.md @@ -0,0 +1,73 @@ +# Program creation and first publication — API contract + +Version: 2026-10-27. Target: dev. Keys below use server snake_case; the existing Angular ApiService camel-cases responses and snake-cases requests. + +## Authorization + +All new endpoints require an authenticated active account. Creation additionally requires Django permission `partner_programs.add_partnerprogram`, assigned through the existing user/group admin. An ordinary staff account or an existing program manager does not automatically receive creation permission. Django superuser semantics remain unchanged. + +Program management, preview and publication use existing `can_manage_program`: program managers or staff/superuser. Removing creation permission does not remove existing program management access. + +Drafts are excluded from the public catalog. Program-scoped routes return 404 to outsiders, including registration/news/solutions/published readers. Draft reads/hidden responses and management responses are private/no-store. Unsafe participant actions on a draft return 404 even for a manager. Existing authorized management/news editing remains available. + +## Create and list + +- `GET /programs/capabilities/` → `{ "can_create_program": true }`. +- `POST /programs/drafts/` accepts exactly `name` (trimmed, nonblank, max255), `format` (`case_championship|hackathon|noncompetitive`) and `idempotency_key` (UUID). +- New object: 201; same creator/key/input retry: 200 with the original object; reuse with different input: 409 `creation_key_reused`. Permission is checked before creation and again under the creator lock. +- Creator becomes a manager, without enrollment. Dates remain null. No emails or publication side effects occur. +- Competitive formats use the existing solution template/storage and opt in only the new program. Noncompetitive uses the existing noncompetitive legacy-project mode. +- `GET /programs/managed/?search=&limit=10&offset=0` → `{count,next,previous,results}`. Limit1–100, offset≥0, search≤255. Managers see their own objects; staff/superusers retain existing all-program management scope. Sort: newest first. + +Summary object: `id`, `name`, `draft`, `program_format` (nullable for legacy), `solution_storage_mode`, `updated_at`, `datetime_started`, `datetime_finished`, `datetime_registration_ends` (dates nullable for draft). + +Legacy `POST /programs/` also requires the explicit creation permission and assigns creator/manager. Its serializer payload remains compatible and model-default draft status is retained. + +## Existing editors + +Basic/dates/registration editing continues through existing management endpoints and optimistic-lock contracts. Draft dates can be partial/null; published programs reject null required dates. Migration0040 also enforces nonnull dates when `draft=false`. + +Registration settings add `program_draft`. For a draft, `POST .../management/registration/publish/` validates/saves the questionnaire but leaves its public version unset. Angular labels this action “Сохранить анкету”. The program publication transaction opens both program and registration. For existing published programs, questionnaire publication behavior stays unchanged. + +## Readiness + +`GET /programs/{id}/management/launch/` returns: + +- `program`: summary above; +- `revision`: SHA256 of saved launch-affecting state; +- `checks`: three `{code,title,section,ready,detail}` objects; codes `page|dates|registration`, sections `basic|dates|registration`; +- `blockers`: `{code,section,detail}` for unmet checks; +- `warnings`: optional work that does not prevent enrollment; +- `can_publish`: draft with no blockers; +- `links`: `{program,registration}`; registration null until publication. + +Minimum: nonblank name/description; all3dates; finish after start; registration deadline in the future; saved valid registration schema. An empty additional questionnaire is valid because account requirements are handled by existing enrollment. External mode additionally requires administrator connection and a valid HTTP(S) URL. No new relative registration/start/finish cutoff is introduced. + +URLs use existing `EXPERT_WORKSPACE_FRONTEND_URL`; set it to the dev frontend origin in dev. Native registration URL remains `/program-registration/{id}`. External registration uses the connected URL. + +## Preview + +`GET /programs/{id}/management/preview/` uses the existing participant detail representation, with `preview=true`, explicit draft status and `preview_widget` for the current empty participant state. It grants no participant permissions. It creates no enrollment/view/email, suppresses welcome/news/certificate side effects, and disables participant actions in Angular. + +The preview questionnaire uses existing registration settings, saved `draft.fields` and the same `RegistrationPreviewContentComponent` as settings. Account fields are outside this existing preview dialog. Native controls/required marks/help/order/source selections are unchanged. + +## First publication + +`POST /programs/{id}/management/publish/` accepts `{ "revision": "64 lowercase hex characters" }`. + +- Program and registration are locked in one transaction. +- Current permission, revision, dates and schema are rechecked. +- 409 `stale_program_launch` if saved launch state changed. +- 400 `{code:"program_not_ready",detail,blockers}` if requirements are unmet. Neither object is partially published. +- Success200 returns readiness state with `program.draft=false`, public links and `can_publish=false`. +- A retry after success returns current published state without incrementing questionnaire version again. + +New competitive program details for registered participants add `solution_action_available` and `solution_action_blocked_reason`, using the existing canonical solution context. An existing solution remains accessible even if creation is disabled. Before form setup, `form_not_configured` disables creation and supplies a clear explanation. Form setup later enables the existing flow without another rollout command. Existing programs’ operator rollback remains effective. + +## Migration and rollout + +Apply migration0040 before deploying this API, then deploy Angular. Existing format/creator/key fields remain null; no inferred classification, fake dates or legacy-answer migration occurs. Published dates retain a DB constraint. + +Safe operational rollback: revoke creation permission/hide new Angular entry, retain schema and API draft privacy. Do not reverse nullable fields while incomplete drafts exist, and do not restore the old publicly readable draft API over new draft data. + +Verification and AC audit are in `docs/feature-specs/program-creation-publication.md`. Live dev acceptance follows API→Angular deployment and is separate from local real-API browser QA. diff --git a/docs/feature-specs/program-creation-publication.md b/docs/feature-specs/program-creation-publication.md new file mode 100644 index 00000000..e64ecbd8 --- /dev/null +++ b/docs/feature-specs/program-creation-publication.md @@ -0,0 +1,141 @@ +# Создание программы и первая публикация + +Дата: 27.10.2026. Целевая среда: dev, Angular + API. +Статус: реализовано в отдельных feature-ветках; публикация в dev через связанные PR. +Пользователь разрешил внедрение после аудита и исправлений макетов. + +Макеты: [27 · Создание программы · черновик и публикация](https://www.figma.com/design/tvog0fpEgWgEtr7KUTjbyE/PROCOLLAB-Design-System-v1?node-id=1109-3285). +11 desktop-экранов и 3 мобильных варианта. Existing controls сверены с реальным кодом. +Runtime использует Mont; Figma использует Inter как технический preview, поскольку connector не предоставляет Mont. + +## Задача и подтвержденные решения + +Организатор должен самостоятельно создать программу, вернуться к черновику, +подготовить страницу, сроки и регистрацию, проверить результат и открыть прием. + +- Право создания отдельно выдает администратор. +- Первая публикация требует страницы, сроков и регистрации. Форма решения, критерии и эксперты могут быть настроены позже. +- Форматы: кейс-чемпионат, хакатон, неконкурсная программа. +- Существующие оболочка, элементы, плотность и порядок разделов сохраняются. +- Реализация и PR в dev входят в scope. Production release не входит. + +## Источники и совместимость + +Исходные dev revisions: API `625517e615aa595ad0fe7ad90550317e959c5c3b`, Angular `72f7aa9837e18367239f1db3781c436dbf57d618`. +Перед PR включены новые dev commits: API `7b6a0c4f00d67d4acf35461a23e32900659634d3`, Angular `43f70c10`. +Они добавляют публичный reader опубликованных legacy-проектов и уточняют действующий UI решений. + +Источники: `partner_programs/models.py`, `views.py`, `services/management.py`, +`services/native_registration.py`, `solution_api.py`; Angular `ui/widgets/detail`, +`ui/pages/program/detail/main`, `ui/pages/program/management`, +`ui/pages/program/management/registration`, `ui/widgets/registration-fields`, +`styles/_program-management-controls.scss`, `_ui-foundation.scss`. + +Каталог по-прежнему показывает опубликованные программы. Существующие редакторы +страницы, дат, материалов, формы решения, экспертов и регистрации используются напрямую. +Старые программы не классифицируются автоматически: `program_format=NULL`. +Их технические режимы и флаг операторского отключения создания решений сохранены. + +## Сценарий + +1. На действующей странице программ появляется вкладка «Я организую» и заметная кнопка «Создать программу» для пользователя с правом создания. +2. Создание спрашивает название и один из трех форматов. Отмена не создает объект. +3. Создается приватный черновик без фиктивных дат; автор становится организатором, без регистрации участником. +4. Подготовка показывает три проверки: страница, сроки, регистрация. Первый незавершенный пункт получает primary CTA. +5. Настройки открывают существующие редакторы. Для черновика есть возврат к подготовке. Сохраняются только реальные изменения. +6. Предпросмотр использует действующую страницу зарегистрированного участника; создание решения, регистрация, просмотры и письма не запускаются. +7. Предпросмотр анкеты из настроек и из подготовки использует один компонент, сохраненную схему и disabled controls. +8. Подтверждение публикации повторно проверяет серверное состояние и атомарно публикует программу с текущей анкетой. +9. Успех показывает ссылки, основное действие «Открыть программу» и второстепенные настройки. Повторный запрос не публикует вторую версию. + +## Правила + +- Право создания: `user.has_perm('partner_programs.add_partnerprogram')`. Оно доступно в существующем Django admin пользователя/группы. Обычный staff и менеджер существующей программы не получают его автоматически; Django superuser сохраняет штатную семантику прав. +- Управление и публикация: существующий `can_manage_program`. Отзыв права создания не удаляет управление уже созданной программой. +- Черновики и вложенные program endpoints возвращают 404 посторонним. Ответы черновиков и management имеют `private, no-store`. +- Обязательные данные страницы: непустые название и описание. Обложка, логотип, город, тег и материалы необязательны. +- Для первой публикации нужны начало, окончание программы и дедлайн регистрации; окончание позже начала, регистрация еще открыта. +- Дополнительного отношения регистрации к началу или окончанию программы не введено. Действующий API допускает регистрацию после старта. Уточняющий вопрос об ограничении конца регистрации остается без ответа; сохранено исходное поведение. +- Встроенная анкета может не иметь дополнительных полей: обязательные данные аккаунта запрашивает существующая регистрация. +- Для черновика «Сохранить анкету» валидирует и сохраняет схему. Регистрация открывается вместе с программой. Для опубликованной программы существующая публикация формы остается прежней. +- Внешняя форма требует подключения администратором и валидного HTTP(S) URL. Неподключенная форма блокирует запуск. +- Новые конкурсные программы включают штатный режим/шаблон решения. Создание решения по-прежнему требует членства, открытых сроков и настроенной формы. До настройки формы участник видит объяснение и недоступное действие. +- Неконкурсная программа не требует формы конкурсного решения, критериев или экспертов. +- Создание сериализуется по автору и UUID; публикация блокирует программу и анкету, проверяет revision и актуальные данные. Ошибка откатывает обе записи. + +## UI и continuity + +- Оболочка Angular, sidebar, реальная карточка программы, редакторы, селекты, radio, модалки и иконки сохранены. +- Управление: Участники → Заявки/Проекты → Эксперты → Результаты → Аналитика → Настройки → Сертификаты. Подготовка не добавлена восьмой вкладкой. +- Карточки 20/16 px, заголовки 16/24, компактные действия 32 px, существующие скругления и tokens. +- Главный CTA заполнен фиолетовым; вторичные действия outline. Desktop submit справа. Mobile сохраняет DOM/focus order. +- В readiness нет фиктивного «Сохранить». На успехе primary ведет к программе. +- В существующем редакторе регистрации для draft изменены только lifecycle-статус, пояснение и название действия. +- Все 14 Figma frames проверены. Экран 05 повторяет реальную participant page; экран 08 использует ту же анкету, что экран 11. + +## API contract + +См. `docs/contracts/program-creation-publication.md` в API и Angular PR. + +| Метод | URL | Назначение | +| --- | --- | --- | +| GET | `/programs/capabilities/` | `can_create_program` | +| POST | `/programs/drafts/` | `name`, `format`, `idempotency_key`; 201/200 | +| GET | `/programs/managed/?search=&limit=10&offset=0` | Scoped список с пагинацией | +| GET | `/programs/{id}/management/launch/` | checks, blockers, warnings, revision, links | +| GET | `/programs/{id}/management/preview/` | Read-only participant representation | +| POST | `/programs/{id}/management/publish/` | ожидаемая revision; 400 blockers, 409 stale | + +Старый POST каталога также проверяет право создания и назначает автора организатором. +Nullable даты допускаются только у draft; DB constraint и существующий published serializer не допускают их у публичной программы. + +## Acceptance criteria и audit + +| AC | Требование | Evidence | Verdict | +| --- | --- | --- | --- | +| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | +| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | +| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | +| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | +| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | +| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | +| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | +| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | +| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | +| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | +| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | +| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | +| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | +| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | +| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | +| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | + +## Verification и границы evidence + +- API: 1045 program tests и 135 project tests прошли на настоящем PostgreSQL 18 с Unicode ICU collation. После обновления dev дополнительно прошли 30 launch/public-project tests. +- Локальный harness исключает 4 существующих native PDF tests: WeasyPrint/Pango завершает Python с Windows access violation. В репозитории тесты не пропущены; полный Linux PostgreSQL CI остается обязательным. +- Django check, makemigrations --check, Black и flake8 проходят. Миграция применена на пустой локальной базе. +- Angular: полный `npm run test:ci` — 495 files / 3046 tests плюс solution contract types. После обновления dev — 37 targeted tests, build:pr и browser integration. +- Browser: installed Edge/Playwright, реальный Angular build и Django API с локальной PostgreSQL базой. Тестовые учетные записи и данные изолированы. Внешние API и изображения заблокированы; поэтому remote fallback avatars на скриншотах могут быть недоступны. +- В локальном static QA server изменяется только API origin при отдаче build; файлы окружений и исходный код не переключаются на fake API. +- Проверены case-championship и noncompetitive full flow, создание трех форматов, реальная регистрация участника, readiness, обе анкеты preview, error/retry, no access, no results, mobile 360/390, CTA text color и отсутствие page errors. +- Live dev: отдельная сессия возвращает на login. Авторизованное deployed состояние не проверено; локальные проверки не выдаются за dev acceptance. +- Figma: 11 desktop + 3 mobile, editable layers и source instances. Mont недоступен connector; не заявляется pixel-perfect совпадение метрик Inter/Mont или полный accessibility audit. + +## Review / rollout + +Requirement audit выполнен по исходным AC. Найденные permission/privacy, query-budget, +DI, пустое уведомление, порядок focus и цвет текста CTA исправлены и перепроверены. +Открытых BLOCKER/MAJOR по локальному evidence нет. Linux CI и live dev acceptance отслеживаются отдельно. + +Порядок: API PR → миграция 0040 и API dev deployment → Angular PR → dev smoke. +Оба PR направляются в dev; merge выполняет пользователь по project workflow. +До успешного API deployment создание не следует включать в доступном Angular UI. +Администратор выдает право выбранным пользователям через Django admin. + +Rollback: убрать право создания и откатить Angular UI; сохранить API privacy boundary и новую схему. +Обратная миграция nullable → NOT NULL требует явного решения по черновикам без дат; +автоматически удалять их или подставлять даты нельзя. Старый API без защиты draft откатывать поверх новых данных нельзя. + +Out of scope: снятие с публикации/архивирование, копирование программ, новый общий +редактор, миграция legacy ответов, React, production release. Подключение внешней формы остается административным. diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py index 8fbcf5cb..dd0ed246 100644 --- a/partner_programs/tests/test_program_launch.py +++ b/partner_programs/tests/test_program_launch.py @@ -131,6 +131,8 @@ def test_draft_is_hidden_across_public_endpoints(self): "news/", "filters/", "projects/", + "published-projects/", + "published-solutions/", "registration/", "solution-context/", "solution-form/", From 0f59e0c243a675ac367ec4c8221bb7dde1cca842 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 01:41:21 +0300 Subject: [PATCH 3/7] test(programs): require database row locks for launch concurrency --- partner_programs/tests/test_program_launch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py index dd0ed246..c0e99d05 100644 --- a/partner_programs/tests/test_program_launch.py +++ b/partner_programs/tests/test_program_launch.py @@ -2,7 +2,7 @@ from unittest.mock import patch from django.contrib.auth.models import Permission -from django.test import TestCase, TransactionTestCase +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature from django.utils import timezone from rest_framework.test import APIClient @@ -407,6 +407,7 @@ def test_later_solution_configuration_needs_no_technical_rollout(self): ) +@skipUnlessDBFeature("has_select_for_update") class ProgramLaunchConcurrencyTests(TransactionTestCase): def setUp(self): self.creator = create_user(is_active=True) From c2d02f45e4840b85fe5487745ce6386ed8a75d8e Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 01:49:09 +0300 Subject: [PATCH 4/7] fix(programs): hide drafts from personal program lists --- partner_programs/tests/test_program_launch.py | 18 ++++++++++++++++++ users/views.py | 10 ++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py index c0e99d05..5d622b58 100644 --- a/partner_programs/tests/test_program_launch.py +++ b/partner_programs/tests/test_program_launch.py @@ -186,6 +186,24 @@ def test_project_creation_cannot_bind_to_an_unpublished_program(self): ) self.assertEqual(response.status_code, 404, response.data) + def test_personal_program_lists_do_not_reveal_drafts_even_with_a_profile(self): + hidden = PartnerProgram.objects.get(pk=self.create().data["id"]) + published = create_partner_program(draft=False) + participant = create_user() + for program in (hidden, published): + PartnerProgramUserProfile.objects.create( + partner_program=program, user=participant, partner_program_data={} + ) + self.client.force_authenticate(participant) + for url in ( + "/auth/users/current/programs/", + "/auth/users/current/programs/tags/", + ): + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual([row["id"] for row in response.data], [published.pk]) + def test_draft_reads_and_hidden_responses_are_not_cacheable(self): pk = self.create().data["id"] response = self.client.get(f"/programs/{pk}/") diff --git a/users/views.py b/users/views.py index 56f3e0c9..0afc8231 100644 --- a/users/views.py +++ b/users/views.py @@ -165,7 +165,10 @@ def get(self, request, *args, **kwargs): user = User.objects.get(id=request.user.id) # fixme: mb hide finished programs programs = [ - profile.partner_program for profile in user.partner_program_profiles.all() + profile.partner_program + for profile in user.partner_program_profiles.select_related( + "partner_program" + ).filter(partner_program__draft=False) ] serializer = self.get_serializer(programs, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -180,7 +183,10 @@ def get(self, request, *args, **kwargs): user = User.objects.get(id=request.user.id) # fixme: mb hide finished programs programs = [ - profile.partner_program for profile in user.partner_program_profiles.all() + profile.partner_program + for profile in user.partner_program_profiles.select_related( + "partner_program" + ).filter(partner_program__draft=False) ] serializer = self.get_serializer(programs, many=True) return Response(serializer.data, status=status.HTTP_200_OK) From 30a3ebaff19ed3ffad7ff8ba0d744c5a2dc33926 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 02:37:22 +0300 Subject: [PATCH 5/7] fix(programs): expose the new creator page without participant enrollment --- .../contracts/program-creation-publication.md | 3 + .../program-creation-publication.md | 59 ++++++++++--------- partner_programs/tests/test_program_launch.py | 14 +++++ partner_programs/views.py | 8 ++- 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/docs/contracts/program-creation-publication.md b/docs/contracts/program-creation-publication.md index e1868452..54d30d27 100644 --- a/docs/contracts/program-creation-publication.md +++ b/docs/contracts/program-creation-publication.md @@ -71,3 +71,6 @@ Apply migration0040 before deploying this API, then deploy Angular. Existing for Safe operational rollback: revoke creation permission/hide new Angular entry, retain schema and API draft privacy. Do not reverse nullable fields while incomplete drafts exist, and do not restore the old publicly readable draft API over new draft data. Verification and AC audit are in `docs/feature-specs/program-creation-publication.md`. Live dev acceptance follows API→Angular deployment and is separate from local real-API browser QA. + +New program managers receive the full page representation on the normal detail route without changing is_user_member or creating enrollment. Legacy program representation stays unchanged. Recovery from a lost publication response confirms saved state via GET; Angular clears the obsolete error and invalidates the scoped public-detail cache before opening the program. + diff --git a/docs/feature-specs/program-creation-publication.md b/docs/feature-specs/program-creation-publication.md index e64ecbd8..69da1814 100644 --- a/docs/feature-specs/program-creation-publication.md +++ b/docs/feature-specs/program-creation-publication.md @@ -1,3 +1,5 @@ + + # Создание программы и первая публикация Дата: 27.10.2026. Целевая среда: dev, Angular + API. @@ -77,45 +79,45 @@ Runtime использует Mont; Figma использует Inter как те См. `docs/contracts/program-creation-publication.md` в API и Angular PR. -| Метод | URL | Назначение | -| --- | --- | --- | -| GET | `/programs/capabilities/` | `can_create_program` | -| POST | `/programs/drafts/` | `name`, `format`, `idempotency_key`; 201/200 | -| GET | `/programs/managed/?search=&limit=10&offset=0` | Scoped список с пагинацией | -| GET | `/programs/{id}/management/launch/` | checks, blockers, warnings, revision, links | -| GET | `/programs/{id}/management/preview/` | Read-only participant representation | -| POST | `/programs/{id}/management/publish/` | ожидаемая revision; 400 blockers, 409 stale | +| Метод | URL | Назначение | +| ----- | ---------------------------------------------- | -------------------------------------------- | +| GET | `/programs/capabilities/` | `can_create_program` | +| POST | `/programs/drafts/` | `name`, `format`, `idempotency_key`; 201/200 | +| GET | `/programs/managed/?search=&limit=10&offset=0` | Scoped список с пагинацией | +| GET | `/programs/{id}/management/launch/` | checks, blockers, warnings, revision, links | +| GET | `/programs/{id}/management/preview/` | Read-only participant representation | +| POST | `/programs/{id}/management/publish/` | ожидаемая revision; 400 blockers, 409 stale | Старый POST каталога также проверяет право создания и назначает автора организатором. Nullable даты допускаются только у draft; DB constraint и существующий published serializer не допускают их у публичной программы. ## Acceptance criteria и audit -| AC | Требование | Evidence | Verdict | -| --- | --- | --- | --- | -| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | -| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | -| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | -| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | -| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | -| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | -| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | -| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | -| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | -| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | -| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | -| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | -| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | -| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | -| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | -| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | +| AC | Требование | Evidence | Verdict | +| ---- | --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- | +| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | +| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | +| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | +| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | +| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | +| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | +| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | +| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | +| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | +| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | +| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | +| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | +| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | +| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | +| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | +| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | ## Verification и границы evidence - API: 1045 program tests и 135 project tests прошли на настоящем PostgreSQL 18 с Unicode ICU collation. После обновления dev дополнительно прошли 30 launch/public-project tests. - Локальный harness исключает 4 существующих native PDF tests: WeasyPrint/Pango завершает Python с Windows access violation. В репозитории тесты не пропущены; полный Linux PostgreSQL CI остается обязательным. - Django check, makemigrations --check, Black и flake8 проходят. Миграция применена на пустой локальной базе. -- Angular: полный `npm run test:ci` — 495 files / 3046 tests плюс solution contract types. После обновления dev — 37 targeted tests, build:pr и browser integration. +- Angular: полный `npm run test:ci` — 496 files / 3055 tests плюс solution contract types. После обновления dev — 37 targeted tests, build:pr и browser integration. - Browser: installed Edge/Playwright, реальный Angular build и Django API с локальной PostgreSQL базой. Тестовые учетные записи и данные изолированы. Внешние API и изображения заблокированы; поэтому remote fallback avatars на скриншотах могут быть недоступны. - В локальном static QA server изменяется только API origin при отдаче build; файлы окружений и исходный код не переключаются на fake API. - Проверены case-championship и noncompetitive full flow, создание трех форматов, реальная регистрация участника, readiness, обе анкеты preview, error/retry, no access, no results, mobile 360/390, CTA text color и отсутствие page errors. @@ -139,3 +141,6 @@ Rollback: убрать право создания и откатить Angular U Out of scope: снятие с публикации/архивирование, копирование программ, новый общий редактор, миграция legacy ответов, React, production release. Подключение внешней формы остается административным. + +Дополнительная verification: восстановление после потерянного POST подтверждено реальным браузером: GET проверяет публикацию, устаревшая ошибка убирается, EntityCache инвалидируется и карточка запрашивается заново. Новые организаторы получают полные данные страницы без автоматической регистрации участником; 27 launch API tests на PostgreSQL и отдельный browser scenario прошли. Обычный Linux CI на предыдущей ревизии: 1879 tests, OK (75 feature skips), включая native PDF renderer. После финального исправления полного доступа организатора CI на новой ревизии требуется повторно. + diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py index 5d622b58..fe16b5a2 100644 --- a/partner_programs/tests/test_program_launch.py +++ b/partner_programs/tests/test_program_launch.py @@ -186,6 +186,20 @@ def test_project_creation_cannot_bind_to_an_unpublished_program(self): ) self.assertEqual(response.status_code, 404, response.data) + def test_new_program_manager_can_read_page_without_becoming_a_participant(self): + program = PartnerProgram.objects.get(pk=self.create().data["id"]) + program.description = "Описание новой программы" + program.save() + response = self.client.get(f"/programs/{program.pk}/") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["description"], program.description) + self.assertTrue(response.data["is_user_manager"]) + self.assertFalse(response.data["is_user_member"]) + self.assertIsNone(response.data["welcome_acknowledged_at"]) + self.assertFalse( + PartnerProgramUserProfile.objects.filter(partner_program=program).exists() + ) + def test_personal_program_lists_do_not_reveal_drafts_even_with_a_profile(self): hidden = PartnerProgram.objects.get(pk=self.create().data["id"]) published = create_partner_program(draft=False) diff --git a/partner_programs/views.py b/partner_programs/views.py index 68fa19d4..e2c269e9 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -187,9 +187,15 @@ def get(self, request, *args, **kwargs): program_id=program.pk, user_id=request.user.pk, ) + # New creators manage the page without becoming participants. + manager_page = bool( + not is_user_member + and program.program_format + and can_manage_program(request.user, program) + ) serializer_class = ( PartnerProgramForMemberSerializer - if is_user_member + if is_user_member or manager_page else PartnerProgramForUnregisteredUserSerializer ) serializer = serializer_class( From b7050c04069f9f33b55e175340fa4ffedfa9243f Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 02:38:33 +0300 Subject: [PATCH 6/7] docs(programs): normalize final contract whitespace --- docs/contracts/program-creation-publication.md | 1 - docs/feature-specs/program-creation-publication.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/contracts/program-creation-publication.md b/docs/contracts/program-creation-publication.md index 54d30d27..b90f744d 100644 --- a/docs/contracts/program-creation-publication.md +++ b/docs/contracts/program-creation-publication.md @@ -73,4 +73,3 @@ Safe operational rollback: revoke creation permission/hide new Angular entry, re Verification and AC audit are in `docs/feature-specs/program-creation-publication.md`. Live dev acceptance follows API→Angular deployment and is separate from local real-API browser QA. New program managers receive the full page representation on the normal detail route without changing is_user_member or creating enrollment. Legacy program representation stays unchanged. Recovery from a lost publication response confirms saved state via GET; Angular clears the obsolete error and invalidates the scoped public-detail cache before opening the program. - diff --git a/docs/feature-specs/program-creation-publication.md b/docs/feature-specs/program-creation-publication.md index 69da1814..9368f102 100644 --- a/docs/feature-specs/program-creation-publication.md +++ b/docs/feature-specs/program-creation-publication.md @@ -143,4 +143,3 @@ Out of scope: снятие с публикации/архивирование, редактор, миграция legacy ответов, React, production release. Подключение внешней формы остается административным. Дополнительная verification: восстановление после потерянного POST подтверждено реальным браузером: GET проверяет публикацию, устаревшая ошибка убирается, EntityCache инвалидируется и карточка запрашивается заново. Новые организаторы получают полные данные страницы без автоматической регистрации участником; 27 launch API tests на PostgreSQL и отдельный browser scenario прошли. Обычный Linux CI на предыдущей ревизии: 1879 tests, OK (75 feature skips), включая native PDF renderer. После финального исправления полного доступа организатора CI на новой ревизии требуется повторно. - From 7ba2a9768e4c696cd990c24c66f0704ca07eb7ae Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 12:39:50 +0300 Subject: [PATCH 7/7] fix(programs): bound first registration by program finish --- .../contracts/program-creation-publication.md | 2 +- .../program-creation-publication.md | 41 ++++++++++--------- partner_programs/services/program_launch.py | 4 +- partner_programs/tests/test_program_launch.py | 33 +++++++++++++++ 4 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/contracts/program-creation-publication.md b/docs/contracts/program-creation-publication.md index b90f744d..22981700 100644 --- a/docs/contracts/program-creation-publication.md +++ b/docs/contracts/program-creation-publication.md @@ -41,7 +41,7 @@ Registration settings add `program_draft`. For a draft, `POST .../management/reg - `can_publish`: draft with no blockers; - `links`: `{program,registration}`; registration null until publication. -Minimum: nonblank name/description; all3dates; finish after start; registration deadline in the future; saved valid registration schema. An empty additional questionnaire is valid because account requirements are handled by existing enrollment. External mode additionally requires administrator connection and a valid HTTP(S) URL. No new relative registration/start/finish cutoff is introduced. +Minimum: nonblank name/description; all3dates; finish after start; registration deadline in the future; saved valid registration schema. An empty additional questionnaire is valid because account requirements are handled by existing enrollment. External mode additionally requires administrator connection and a valid HTTP(S) URL. For first publication, the registration deadline may be after the program starts but must be no later than the program finish (equality is valid). A deadline after finish returns a dates blocker and publication HTTP400 without changing program/questionnaire state. Existing published-program date editing is unchanged. URLs use existing `EXPERT_WORKSPACE_FRONTEND_URL`; set it to the dev frontend origin in dev. Native registration URL remains `/program-registration/{id}`. External registration uses the connected URL. diff --git a/docs/feature-specs/program-creation-publication.md b/docs/feature-specs/program-creation-publication.md index 9368f102..c5817e6f 100644 --- a/docs/feature-specs/program-creation-publication.md +++ b/docs/feature-specs/program-creation-publication.md @@ -57,7 +57,7 @@ Runtime использует Mont; Figma использует Inter как те - Черновики и вложенные program endpoints возвращают 404 посторонним. Ответы черновиков и management имеют `private, no-store`. - Обязательные данные страницы: непустые название и описание. Обложка, логотип, город, тег и материалы необязательны. - Для первой публикации нужны начало, окончание программы и дедлайн регистрации; окончание позже начала, регистрация еще открыта. -- Дополнительного отношения регистрации к началу или окончанию программы не введено. Действующий API допускает регистрацию после старта. Уточняющий вопрос об ограничении конца регистрации остается без ответа; сохранено исходное поведение. +- Подтверждено пользователем 27.10.2026: регистрация может продолжаться после старта, но завершается не позже окончания программы. Равенство дедлайна регистрации окончанию программы допустимо. Проверка действует при первой публикации; существующие опубликованные программы и редакторы дат не меняются. - Встроенная анкета может не иметь дополнительных полей: обязательные данные аккаунта запрашивает существующая регистрация. - Для черновика «Сохранить анкету» валидирует и сохраняет схему. Регистрация открывается вместе с программой. Для опубликованной программы существующая публикация формы остается прежней. - Внешняя форма требует подключения администратором и валидного HTTP(S) URL. Неподключенная форма блокирует запуск. @@ -93,24 +93,25 @@ Nullable даты допускаются только у draft; DB constraint и ## Acceptance criteria и audit -| AC | Требование | Evidence | Verdict | -| ---- | --------------------------------------------------- | ----------------------------------------------------------------------------------- | ------- | -| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | -| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | -| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | -| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | -| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | -| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | -| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | -| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | -| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | -| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | -| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | -| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | -| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | -| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | -| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | -| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | +| AC | Требование | Evidence | Verdict | +| ---- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------- | +| AC01 | Создание только по отдельному праву | API capabilities/create/legacy POST negatives; browser no-access | PASS | +| AC02 | Автор управляет, без автоматического участия | Creation API tests + real browser editors | PASS | +| AC03 | Черновик скрыт в каталоге и по прямым URL | API negatives, all program route boundary, no-store tests | PASS | +| AC04 | Собственный список с поиском/пагинацией и возвратом | Scoped API tests; browser error/retry/search; URL query source | PASS | +| AC05 | Только имя/формат, без фиктивных дат | API three formats/null dates; browser create/checklist | PASS | +| AC06 | Server blockers без частичной публикации | API validation/rollback/concurrency tests | PASS | +| AC07 | Публикация до формы решения/оценки/экспертов | API tests; real Angular/API full flow and participant enrollment | PASS | +| AC08 | Внешняя регистрация ждет подключения | API connected/not-connected; browser readiness disabled and explanation | PASS | +| AC09 | Изменение настроек требует обновления revision | API stale page/schema and concurrent publication tests | PASS | +| AC10 | Повтор create/publish не создает дубликаты | API concurrency/idempotency; component lost-response retry | PASS | +| AC11 | Preview без участия, просмотров и писем | API no-enrollment/no-view; browser shared preview; source no writes | PASS | +| AC12 | Поздняя настройка формы без ручного rollout | API canonical solution context before/after config and operator rollback | PASS | +| AC13 | Неконкурсный запуск без конкурсных требований | API publication; real Angular/API full noncompetitive flow | PASS | +| AC14 | Desktop/390/360, длинное имя, empty/errors | Browser no overflow, 255 chars, denied/retry/no-results; keyboard focus source/test | PASS | +| AC15 | Один renderer/схема предпросмотра анкеты | Shared component; browser saved fields order/help/disabled; registration tests | PASS | +| AC16 | Existing UI совпадает с реальной платформой | Source continuity + native Mont browser screenshots + Figma audit | PASS | +| AC17 | Первая публикация: регистрация после старта разрешена, дедлайн не позже окончания включительно | 29 launch tests: PostgreSQL PASS; SQLite PASS (2 DB feature skips); negative publish без изменений | PASS | ## Verification и границы evidence @@ -143,3 +144,5 @@ Out of scope: снятие с публикации/архивирование, редактор, миграция legacy ответов, React, production release. Подключение внешней формы остается административным. Дополнительная verification: восстановление после потерянного POST подтверждено реальным браузером: GET проверяет публикацию, устаревшая ошибка убирается, EntityCache инвалидируется и карточка запрашивается заново. Новые организаторы получают полные данные страницы без автоматической регистрации участником; 27 launch API tests на PostgreSQL и отдельный browser scenario прошли. Обычный Linux CI на предыдущей ревизии: 1879 tests, OK (75 feature skips), включая native PDF renderer. После финального исправления полного доступа организатора CI на новой ревизии требуется повторно. + +Уточнение AC17 (27.10.2026): организатор публикует подготовленный черновик с регистрацией после старта и до окончания программы включительно → успех; дедлайн регистрации позже окончания хотя бы на секунду → dates blocker и HTTP 400 без публикации программы/анкеты. Проверка: API tests readiness + publish, граничное равенство. Реализация: program_launch.\_date_problem, response detail отображается существующим Angular checklist без изменения wire schema, ролей и legacy редакторов. Verification AC17: 29 launch tests на PostgreSQL PASS; SQLite PASS с 2 DB-feature skips; Black/flake8 PASS. Figma states 09 обновлен, screenshot без overflow. Angular runtime source не менялся: server detail отображается существующим checklist. Полный CI нового head отслеживается в PR отдельно. diff --git a/partner_programs/services/program_launch.py b/partner_programs/services/program_launch.py index d723d0e8..c29816e0 100644 --- a/partner_programs/services/program_launch.py +++ b/partner_programs/services/program_launch.py @@ -169,10 +169,10 @@ def _date_problem(program): return "Укажите начало, окончание программы и окончание регистрации." if program.datetime_finished <= program.datetime_started: return "Окончание программы должно быть позже начала." - # Enrollment may continue after the program starts, as in the existing API. - # No new relative registration cutoff is introduced by this launch flow. if program.datetime_registration_ends <= timezone.now(): return "Окончание регистрации должно быть в будущем, чтобы открыть прием." + if program.datetime_registration_ends > program.datetime_finished: + return "Регистрация должна завершиться не позже окончания программы." return None diff --git a/partner_programs/tests/test_program_launch.py b/partner_programs/tests/test_program_launch.py index fe16b5a2..5aa403f9 100644 --- a/partner_programs/tests/test_program_launch.py +++ b/partner_programs/tests/test_program_launch.py @@ -390,6 +390,39 @@ def test_program_end_must_follow_start_but_registration_can_continue_after_start self.assertEqual([r["code"] for r in self.state()["blockers"]], ["dates"]) self.assertEqual(self.publish().status_code, 400) + def test_registration_after_program_finish_blocks_publication_without_changes(self): + config = self.configure() + self.program.datetime_registration_ends = ( + self.program.datetime_finished + timezone.timedelta(seconds=1) + ) + self.program.save() + state = self.state() + self.assertFalse(state["can_publish"]) + self.assertEqual([r["code"] for r in state["blockers"]], ["dates"]) + self.assertEqual( + state["blockers"][0]["detail"], + "Регистрация должна завершиться не позже окончания программы.", + ) + response = self.publish(state["revision"]) + self.assertEqual(response.status_code, 400, response.data) + self.assertEqual(response.data["code"], "program_not_ready") + self.program.refresh_from_db() + config.refresh_from_db() + self.assertTrue(self.program.draft) + self.assertIsNone(config.published) + self.assertIsNone(config.published_at) + self.assertEqual(config.published_version, 0) + + def test_registration_can_finish_exactly_when_the_program_finishes(self): + self.configure() + self.program.datetime_registration_ends = self.program.datetime_finished + self.program.save() + self.assertTrue(self.state()["can_publish"]) + response = self.publish() + self.assertEqual(response.status_code, 200, response.data) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + def test_published_program_keeps_existing_nonnullable_dates_contract(self): self.configure() self.publish()