diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index d58b7248..ae3e33d4 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -80,3 +80,21 @@ GET `management/projects/export/` использует тот же ManagementPro `management/projects/:link_id/evaluation/` дополнительно возвращает `criteria` и `result` из канонического расчета Results, ограниченного этой связью программы и проекта. Старые поля `experts`/`can_rate` сохранены. В просмотре организатора используются общая матрица и мобильные карточки Results; отдельная кнопка личного оценивания убрана. Средние и итог учитывают только полные валидные оценки, без изменения правил рейтинга. Сданный проект с `draft=True` остается недоступен для назначения экспертов: в таблице показывается «Черновик заявки», а плашка выбора поясняет необходимость завершить оформление заявки. Это не то же, что публичность проекта (`is_public`). Автоматическая публикация и изменение пользовательских данных не выполняются. Фильтры проектов применяются вместе из модального окна; выгрузка и выбор всех используют примененные фильтры. + +## Возврат в черновик + +`POST /programs/{id}/management/unpublish/` принимает обязательный +`expected_updated_at` (ISO datetime), возвращает обычный management-ответ +с `draft=true`. Менеджер этой программы либо существующий staff/superuser scope +проверяется сервером. Право создания программы не требуется. Без авторизации — 401; +для чужой опубликованной программы — 403, скрытого черновика или отсутствующей — 404. +Неверные/лишние поля — 400, устаревшая версия — 409 `stale_settings` без записи. +Повтор уже выполненного действия — 200 без изменения версии. + +Общий сервис API и подтверждения Django admin блокирует строку программы и меняет +только `draft` и `datetime_updated`. Участие, ответы анкет, заявки, решения, +оценки, материалы, сроки и опубликованная версия анкеты сохраняются. Публичные +маршруты используют прежнее сокрытие черновиков. Повторная публикация идет через +существующую подготовку к запуску со всеми проверками готовности; сроки не продлеваются. +Миграций нет; API выкладывается до Angular. AC DRAFT-1…DRAFT-7 описаны в +Angular `docs/modules/program-management.md`. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 58ca46c0..11ace654 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -29,6 +29,7 @@ ) from partner_programs.services.case_fields import case_field_has_values from partner_programs.admin_registration import ProgramRegistrationAdminMixin +from partner_programs.admin_publication import ProgramPublicationAdminMixin from partner_programs.program_dates import ( DATE_FIELDS, program_date_errors, @@ -51,7 +52,9 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) -class PartnerProgramAdmin(ProgramRegistrationAdminMixin, admin.ModelAdmin): +class PartnerProgramAdmin( + ProgramPublicationAdminMixin, ProgramRegistrationAdminMixin, admin.ModelAdmin +): class PartnerProgramAdminForm(forms.ModelForm): confirm_reopen = forms.BooleanField( required=False, diff --git a/partner_programs/admin_publication.py b/partner_programs/admin_publication.py new file mode 100644 index 00000000..332fb678 --- /dev/null +++ b/partner_programs/admin_publication.py @@ -0,0 +1,66 @@ +"""Возврат программы в черновик через общую с кабинетом бизнес-логику.""" + +from django import forms +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpResponseNotAllowed, HttpResponseRedirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from rest_framework.exceptions import APIException + +from partner_programs.admin_registration import error_text +from partner_programs.services.program_launch import return_to_draft + + +class ReturnToDraftForm(forms.Form): + expected_updated_at = forms.DateTimeField(widget=forms.HiddenInput) + + +class ProgramPublicationAdminMixin: + def get_urls(self): + return [ + path( + "/unpublish/", + self.admin_site.admin_view(self.return_to_draft_view), + name="partner_programs_partnerprogram_unpublish", + ) + ] + super().get_urls() + + def return_to_draft_view(self, request, object_id): + if request.method not in ("GET", "POST"): + return HttpResponseNotAllowed(["GET", "POST"]) + program = self.get_object(request, str(object_id)) + if program is None: + raise Http404 + if not self.has_change_permission(request, program): + raise PermissionDenied + change_url = reverse( + "admin:partner_programs_partnerprogram_change", args=[program.pk] + ) + form = ReturnToDraftForm( + request.POST if request.method == "POST" else None, + initial={"expected_updated_at": program.datetime_updated.isoformat()}, + ) + status = 200 + if request.method == "POST" and form.is_valid(): + try: + program = return_to_draft( + program.pk, request.user, form.cleaned_data["expected_updated_at"] + ) + except APIException as error: + form.add_error(None, error_text(error.detail)) + status = error.status_code + else: + self.log_change(request, program, "Программа возвращена в черновик.") + self.message_user(request, "Программа возвращена в черновик.") + return HttpResponseRedirect(change_url) + context = { + **self.admin_site.each_context(request), + "title": "Вернуть программу в черновик?", + "opts": self.model._meta, + "original": program, + "form": form, + "change_url": change_url, + } + return TemplateResponse( + request, "partner_programs/admin/unpublish.html", context, status=status + ) diff --git a/partner_programs/program_launch_views.py b/partner_programs/program_launch_views.py index 0a3dbc3a..8a0a33de 100644 --- a/partner_programs/program_launch_views.py +++ b/partner_programs/program_launch_views.py @@ -9,6 +9,7 @@ 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 +from partner_programs.services.management import management_data class CreateProgramInput(StrictFieldsMixin, serializers.Serializer): @@ -73,3 +74,17 @@ def post(self, request, pk): return Response( service.publish_program(pk, request.user, data.validated_data["revision"]) ) + + +class ReturnToDraftInput(StrictFieldsMixin, serializers.Serializer): + expected_updated_at = serializers.DateTimeField() + + +class ProgramReturnToDraftView(ManagementBase): + def post(self, request, pk): + data = ReturnToDraftInput(data=request.data) + data.is_valid(raise_exception=True) + program = service.return_to_draft( + pk, request.user, data.validated_data["expected_updated_at"] + ) + return Response(management_data(program)) diff --git a/partner_programs/services/program_launch.py b/partner_programs/services/program_launch.py index 05195f14..aff23fa0 100644 --- a/partner_programs/services/program_launch.py +++ b/partner_programs/services/program_launch.py @@ -247,6 +247,23 @@ def readiness(pk, user): return readiness_data(managed_program(pk, user)) +@transaction.atomic +def return_to_draft(pk, user, expected_updated_at): + """Скрыть программу, сохранив анкету, участие, решения и оценки.""" + program = managed_program(pk, user, lock=True) + if program.draft: + # Повтор после потерянного ответа не меняет версию программы. + return program + if program.datetime_updated != expected_updated_at: + raise WorkspaceConflict( + "stale_settings", + "Программа изменена другим организатором. Обновите данные перед повтором.", + ) + program.draft = True + program.save(update_fields=["draft", "datetime_updated"]) + return program + + @transaction.atomic def publish_program(pk, user, revision): program = managed_program(pk, user, lock=True) diff --git a/partner_programs/tests/test_program_unpublish.py b/partner_programs/tests/test_program_unpublish.py new file mode 100644 index 00000000..d36a37f1 --- /dev/null +++ b/partner_programs/tests/test_program_unpublish.py @@ -0,0 +1,296 @@ +"""Возврат в черновик: права, сохранность данных, повтор и Django admin.""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +from django.contrib.auth.models import Permission +from django.db import connections +from django.test import ( + Client, + TestCase, + TransactionTestCase, + override_settings, + skipUnlessDBFeature, +) +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgram, ProgramRegistration +from partner_programs.services.program_launch import readiness, publish_program +from partner_programs.solution_models import ProgramSolution, ProgramSolutionMember +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_user, +) +from project_rates.models import Criteria, ProjectScore + + +class ProgramUnpublishTests(TestCase): + def setUp(self): + self.manager = create_user(is_active=True) + self.program = create_partner_program() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = f"/programs/{self.program.pk}/management/unpublish/" + self.body = {"expected_updated_at": self.program.datetime_updated.isoformat()} + + def post(self, **changes): + return self.client.post(self.url, {**self.body, **changes}, format="json") + + def test_transition_preserves_records_and_all_other_program_fields(self): + member = create_program_member(self.program, data={"answer": "Сохранить"}) + link = create_program_project(self.program, submitted=True) + criterion = Criteria.objects.create( + partner_program=self.program, name="Критерий", type="int" + ) + score = ProjectScore.objects.create( + criteria=criterion, user=self.manager, project=link.project, value="5" + ) + solution = ProgramSolution.objects.create( + program=self.program, + captain=member.user, + ) + membership = ProgramSolutionMember.objects.create( + solution=solution, user=member.user + ) + solution.status = "submitted" + solution.submitted_at = timezone.now() + solution.submission_snapshot = {"name": "Решение"} + solution.save() + material = self.program.materials.create( + title="Правила", url="https://example.com/rules" + ) + config = ProgramRegistration.objects.create( + program=self.program, + draft={"mode": "native", "fields": []}, + published={"mode": "native", "fields": []}, + published_version=3, + revision=4, + published_at=timezone.now(), + ) + records = [member, link, score, solution, membership, material, config] + before = [ + (obj, type(obj).objects.filter(pk=obj.pk).values().get()) for obj in records + ] + program_before = PartnerProgram.objects.filter(pk=self.program.pk).values().get() + + response = self.post() + + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["draft"]) + self.assertEqual(response["Cache-Control"], "private, no-store") + self.program.refresh_from_db() + self.assertGreater( + self.program.datetime_updated, program_before["datetime_updated"] + ) + program_after = PartnerProgram.objects.filter(pk=self.program.pk).values().get() + for key in ("draft", "datetime_updated"): + program_before.pop(key) + program_after.pop(key) + self.assertEqual(program_before, program_after) + for obj, snapshot in before: + self.assertEqual(type(obj).objects.filter(pk=obj.pk).values().get(), snapshot) + + def test_draft_is_hidden_from_public_and_participants_but_manager_keeps_access(self): + participant = create_program_member(self.program).user + self.assertEqual(self.post().status_code, 200) + for user in (None, participant, create_user()): + self.client.force_authenticate(user) + for suffix in ( + "", + "registration/", + "published-solutions/", + "published-projects/", + ): + self.assertEqual( + self.client.get(f"/programs/{self.program.pk}/{suffix}").status_code, + 404, + suffix, + ) + catalog = self.client.get("/programs/").data + self.assertNotIn(self.program.pk, [row["id"] for row in catalog["results"]]) + self.client.force_authenticate(self.manager) + self.assertEqual( + self.client.get(f"/programs/{self.program.pk}/management/").status_code, 200 + ) + self.assertTrue( + self.client.get(f"/programs/{self.program.pk}/management/launch/").data[ + "program" + ]["draft"] + ) + + def test_permissions_are_object_scoped_without_creation_permission(self): + other_manager = create_user() + create_partner_program().managers.add(other_manager) + for user, expected in ((None, 401), (other_manager, 403)): + self.client.force_authenticate(user) + self.assertEqual(self.post().status_code, expected) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + self.client.force_authenticate(self.manager) + self.assertFalse(self.manager.has_perm("partner_programs.add_partnerprogram")) + self.assertEqual(self.post().status_code, 200) + self.client.force_authenticate(other_manager) + self.assertEqual(self.post().status_code, 404) + + def test_staff_can_return_program_to_draft(self): + self.client.force_authenticate(create_user(is_staff=True)) + self.assertEqual(self.post().status_code, 200) + + def test_missing_program_and_invalid_payloads_do_not_write(self): + missing = self.client.post( + "/programs/2147483647/management/unpublish/", self.body, format="json" + ) + self.assertEqual(missing.status_code, 404) + for body in ({}, {"expected_updated_at": "bad"}, {**self.body, "draft": True}): + self.assertEqual( + self.client.post(self.url, body, format="json").status_code, 400 + ) + self.assertEqual(self.client.get(self.url).status_code, 405) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + + def test_stale_version_rejects_transition_and_retry_is_idempotent(self): + self.program.name = "Изменено другим организатором" + self.program.save(update_fields=["name", "datetime_updated"]) + response = self.post() + self.assertEqual(response.status_code, 409) + self.assertEqual(response.data["code"], "stale_settings") + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + self.assertEqual( + self.post( + expected_updated_at=self.program.datetime_updated.isoformat() + ).status_code, + 200, + ) + self.program.refresh_from_db() + version = self.program.datetime_updated + self.assertEqual(self.post().status_code, 200) + self.program.refresh_from_db() + self.assertEqual(self.program.datetime_updated, version) + + def test_republication_preserves_answers_and_rejects_old_publish_revision(self): + schema = {"mode": "native", "fields": []} + config = ProgramRegistration.objects.create( + program=self.program, + draft=schema, + published=schema, + published_version=1, + ) + member = create_program_member(self.program, data={"historical": "Ответ"}) + previous_revision = readiness(self.program.pk, self.manager)["revision"] + self.assertEqual(self.post().status_code, 200) + response = self.client.post( + f"/programs/{self.program.pk}/management/publish/", + {"revision": previous_revision}, + format="json", + ) + self.assertEqual(response.status_code, 409) + state = readiness(self.program.pk, self.manager) + self.assertTrue(state["can_publish"]) + publish_program(self.program.pk, self.manager, state["revision"]) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + member.refresh_from_db() + self.assertEqual(member.partner_program_data, {"historical": "Ответ"}) + config.refresh_from_db() + self.assertEqual(config.published_version, 2) + self.assertEqual(self.post().status_code, 409) + + +@override_settings( + STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage" +) +class ProgramUnpublishAdminTests(TestCase): + def setUp(self): + self.admin = create_user(is_staff=True, is_superuser=True, is_active=True) + self.client.force_login(self.admin) + self.program = create_partner_program() + self.url = reverse( + "admin:partner_programs_partnerprogram_unpublish", args=[self.program.pk] + ) + self.change_url = reverse( + "admin:partner_programs_partnerprogram_change", args=[self.program.pk] + ) + + def test_admin_get_is_confirmation_only_and_post_uses_same_transition(self): + self.assertContains(self.client.get(self.change_url), self.url) + page = self.client.get(self.url) + self.assertContains(page, "Участники, заявки, решения и оценки сохранятся") + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + response = self.client.post( + self.url, {"expected_updated_at": self.program.datetime_updated.isoformat()} + ) + self.assertRedirects(response, self.change_url) + self.program.refresh_from_db() + self.assertTrue(self.program.draft) + self.assertNotContains(self.client.get(self.change_url), self.url) + + def test_admin_requires_change_permission_and_csrf(self): + staff = create_user(is_staff=True, is_active=True) + self.program.managers.add(staff) + self.client.force_login(staff) + self.assertEqual(self.client.get(self.url).status_code, 403) + self.assertEqual(self.client.post(self.url, {}).status_code, 403) + client = Client(enforce_csrf_checks=True) + client.force_login(self.admin) + self.assertEqual( + client.post( + self.url, + {"expected_updated_at": self.program.datetime_updated.isoformat()}, + ).status_code, + 403, + ) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + staff.user_permissions.add( + Permission.objects.get(codename="change_partnerprogram") + ) + self.client.force_login(staff) + self.assertEqual(self.client.get(self.url).status_code, 200) + + def test_admin_stale_confirmation_keeps_program_published(self): + version = self.program.datetime_updated.isoformat() + self.program.name = "Новое название" + self.program.save() + response = self.client.post(self.url, {"expected_updated_at": version}) + self.assertContains( + response, "Программа изменена другим организатором", status_code=409 + ) + self.program.refresh_from_db() + self.assertFalse(self.program.draft) + + +@skipUnlessDBFeature("has_select_for_update") +class ProgramUnpublishConcurrencyTests(TransactionTestCase): + def test_simultaneous_retries_write_one_transition(self): + manager = create_user(is_active=True) + program = create_partner_program() + program.managers.add(manager) + body = {"expected_updated_at": program.datetime_updated.isoformat()} + barrier = Barrier(2) + + def post(_): + try: + client = APIClient() + client.force_authenticate(type(manager).objects.get(pk=manager.pk)) + barrier.wait(timeout=10) + result = client.post( + f"/programs/{program.pk}/management/unpublish/", body, format="json" + ) + return result.status_code, result.data["updated_at"] + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(post, range(2))) + self.assertEqual([status for status, _ in results], [200, 200]) + self.assertEqual(len({version for _, version in results}), 1) + program.refresh_from_db() + self.assertTrue(program.draft) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index dc2e45d2..02721337 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -153,6 +153,7 @@ ProgramPreviewView, ProgramReadinessView, ProgramPublishView, + ProgramReturnToDraftView, ) app_name = "partner_programs" @@ -176,6 +177,7 @@ ), path("/management/launch/", program_view(ProgramReadinessView)), path("/management/publish/", program_view(ProgramPublishView)), + path("/management/unpublish/", program_view(ProgramReturnToDraftView)), path( "/published-solutions/", program_view(PublishedSolutionListView), diff --git a/templates/partner_programs/admin/program_manager_change_form.html b/templates/partner_programs/admin/program_manager_change_form.html index 9ac16db1..bf46ee80 100644 --- a/templates/partner_programs/admin/program_manager_change_form.html +++ b/templates/partner_programs/admin/program_manager_change_form.html @@ -4,6 +4,9 @@ {% block object-tools-items %} {% if original and has_change_permission %}
  • Регистрация
  • + {% if not original.draft %} +
  • Вернуть в черновик
  • + {% endif %} {% endif %} {{ block.super }} {% endblock %} diff --git a/templates/partner_programs/admin/programs_change_form.html b/templates/partner_programs/admin/programs_change_form.html index becea89e..17fcc100 100644 --- a/templates/partner_programs/admin/programs_change_form.html +++ b/templates/partner_programs/admin/programs_change_form.html @@ -4,6 +4,9 @@ {% block object-tools-items %} {% if original and has_change_permission %}
  • Регистрация
  • + {% if not original.draft %} +
  • Вернуть в черновик
  • + {% endif %} {% endif %} {{ block.super }} {% endblock %} diff --git a/templates/partner_programs/admin/unpublish.html b/templates/partner_programs/admin/unpublish.html new file mode 100644 index 00000000..8f31d112 --- /dev/null +++ b/templates/partner_programs/admin/unpublish.html @@ -0,0 +1,13 @@ +{% extends "admin/base_site.html" %} +{% block content %} +

    {{ original.name }}

    +

    Программа исчезнет из каталога и станет недоступна участникам. Участники, заявки, решения и оценки сохранятся. Программу можно будет опубликовать снова через подготовку к запуску.

    +
    + {% csrf_token %} + {{ form.as_p }} +
    + + Отмена +
    +
    +{% endblock %}