From a8b44688cc3be204e3915013557a079ccb6c8ad0 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 12:47:02 +0300 Subject: [PATCH 1/3] fix(programs): publish finished program work immediately on access --- .github/workflows/dev-ci.yml | 6 +- docs/contracts/program-solutions-v1/README.md | 2 + .../program-solutions-v1/openapi.json | 4 +- partner_programs/published_projects_api.py | 8 +- partner_programs/services/publishing.py | 124 ++++++++---- partner_programs/solution_publication_api.py | 5 +- .../tests/test_dev_publication_audit.py | 56 ++++++ .../tests/test_published_projects_api.py | 49 ++++- .../test_solution_competitive_publication.py | 14 +- .../tests/test_solution_publication.py | 43 +++- partner_programs/views.py | 2 + projects/permissions.py | 9 +- scripts/verify_program_publication_dev.py | 187 ++++++++++++++++++ 13 files changed, 450 insertions(+), 59 deletions(-) create mode 100644 partner_programs/tests/test_dev_publication_audit.py create mode 100644 scripts/verify_program_publication_dev.py diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index bc5cb08b..364f6631 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -10,7 +10,7 @@ on: description: "DEV operation" type: choice default: deploy - options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser] + options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser, verify_program_publication] fixture_key: description: "Unique acceptance fixture key" default: release-20261026 @@ -401,10 +401,10 @@ jobs: const operation = process.env.VERIFY_OPERATION; const key = process.env.VERIFY_FIXTURE_KEY; const manager = process.env.VERIFY_MANAGER_ID; - const phases = { verify_solution_rollback: 'verify_rollback', prepare_solution_browser: 'prepare_browser', finalize_solution_browser: 'finalize_browser' }; + const phases = { verify_solution_rollback: 'verify_rollback', prepare_solution_browser: 'prepare_browser', finalize_solution_browser: 'finalize_browser', verify_program_publication: 'verify_publication' }; if (!/^[0-9a-f]{40}$/.test(sha) || !phases[operation] || !/^[a-z0-9-]{8,50}$/.test(key) || !/^[1-9][0-9]{0,8}$/.test(manager)) throw new Error('Invalid acceptance arguments'); const phase = phases[operation]; - const file = phase === 'verify_rollback' ? 'scripts/verify_program_solutions_dev.py' : 'scripts/prepare_program_solutions_browser.py'; + const file = phase === 'verify_publication' ? 'scripts/verify_program_publication_dev.py' : phase === 'verify_rollback' ? 'scripts/verify_program_solutions_dev.py' : 'scripts/prepare_program_solutions_browser.py'; const source = fs.readFileSync(file, 'utf8'); const script = [ 'set +x', diff --git a/docs/contracts/program-solutions-v1/README.md b/docs/contracts/program-solutions-v1/README.md index 1d9a0d6f..6629b7e5 100644 --- a/docs/contracts/program-solutions-v1/README.md +++ b/docs/contracts/program-solutions-v1/README.md @@ -323,3 +323,5 @@ python validate_contract.py ## Изменение 1.14 Первая сдача организатором Два отдельных POST management/solutions/actions/preview и apply сохраняют существующее organizer/admin исключение поздней первой сдачи. action=submit и 1–10000 safe Solution IDs; signed preview на 10 минут привязан к actor/program/IDs и сохраненным материалам/составу/форме/policy/files. Предпросмотр не пишет, apply повторяет проверки атомарно и создает обычный accepted-only snapshot. Уже сданные данные/дата и captain deadline неизменны. Nullable token/can_apply/counts/per-solution Issue[] и changed_count/already_submitted_count зафиксированы. Ошибки C00/private,no-store. 125 schemas/47 operations/185 scenarios; прежние 120/45/161 структурно неизменны. Миграций нет. UI ожидает D02; post-submission case/detach/removal сюда не входят. + +Уточнение 27.10.2026: публикация после окончания программы больше не зависит от ежедневного задания. Обычный GET страницы программы, списка или материалов немедленно фиксирует серверную отметку публикации при включенном флаге. Прямые ссылки на допустимые legacy-проекты также открываются сразу. Повторный GET не меняет published_at; выключение флага или перенос срока не отзывает ранее опубликованные работы. Черновики программ/проектов, архивы решений и несданные конкурсные решения остаются закрытыми. Периодические задания сохранены для восстановления отметок; DTO и права записи не меняются. diff --git a/docs/contracts/program-solutions-v1/openapi.json b/docs/contracts/program-solutions-v1/openapi.json index c425736c..02f502a0 100644 --- a/docs/contracts/program-solutions-v1/openapi.json +++ b/docs/contracts/program-solutions-v1/openapi.json @@ -8430,7 +8430,7 @@ } }, "security": [], - "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. Доступ определяет сохраненный published_at, архивы исключаются; GET не публикует. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." + "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. После окончания программы при включенной публикации первый GET немедленно фиксирует серверный published_at без ожидания периодического задания. Ранее опубликованные материалы сохраняют доступ; архивы и черновики программ исключаются. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." } }, "/programs/{program_id}/published-solutions/{solution_id}/": { @@ -8502,7 +8502,7 @@ } }, "security": [], - "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. Доступ определяет сохраненный published_at, архивы исключаются; GET не публикует. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." + "description": "Публичное read-only чтение опубликованных материалов solution программы. Неконкурсная программа: сохраненные материалы без искусственной сдачи; конкурсная: уже сданные решения и действующий снимок сдачи. Eligibility несданных конкурсных черновиков остается открытым вопросом, пока они не публикуются. После окончания программы при включенной публикации первый GET немедленно фиксирует серверный published_at без ожидания периодического задания. Ранее опубликованные материалы сохраняют доступ; архивы и черновики программ исключаются. Пустой конкурсный список дает200, legacy mode404. Закрытые оценки, назначения, pending и личные данные регистрации не раскрываются. Cache-Control no-store." } }, "/programs/{program_id}/management/solutions/actions/preview/": { diff --git a/partner_programs/published_projects_api.py b/partner_programs/published_projects_api.py index 976642b9..3f6f41fe 100644 --- a/partner_programs/published_projects_api.py +++ b/partner_programs/published_projects_api.py @@ -8,6 +8,7 @@ from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from partner_programs.pagination import PartnerProgramPagination +from partner_programs.services.publishing import publish_program_if_due from projects.models import Project from projects.serializers import ProjectListSerializer @@ -30,6 +31,11 @@ class PublishedProgramProjectsView(generics.ListAPIView): serializer_class = ProjectListSerializer pagination_class = PublishedProjectsPagination + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "no-store" + return response + def get_queryset(self): program = get_object_or_404( PartnerProgram, @@ -39,7 +45,7 @@ def get_queryset(self): publish_projects_after_finish=True, datetime_finished__lte=timezone.now(), ) - # The existing publisher can use either source. Never publish as a GET side effect. + publish_program_if_due(program) projects = ( Project.objects.filter( Q(program_links__partner_program=program) diff --git a/partner_programs/services/publishing.py b/partner_programs/services/publishing.py index 1e071ec2..0aaf3ad3 100644 --- a/partner_programs/services/publishing.py +++ b/partner_programs/services/publishing.py @@ -1,49 +1,61 @@ -from django.utils import timezone +"""Publish eligible work on access; periodic tasks also repair publication markers.""" + from django.db import transaction +from django.db.models import Q +from django.utils import timezone from partner_programs.models import ( PartnerProgram, - PartnerProgramProject, PartnerProgramUserProfile, ) from projects.models import Project from partner_programs.solution_models import ProgramSolution -def publish_finished_program_projects(now=None) -> int: - if now is None: - now = timezone.now() - - program_ids = PartnerProgram.objects.filter( - solution_storage_mode="legacy_project", +def publication_filter(now, mode): + return dict( + draft=False, + solution_storage_mode=mode, publish_projects_after_finish=True, datetime_finished__lte=now, - ).values_list("id", flat=True) - if not program_ids.exists(): - return 0 + ) - link_project_ids = PartnerProgramProject.objects.filter( - partner_program_id__in=program_ids - ).values_list("project_id", flat=True) - profile_project_ids = PartnerProgramUserProfile.objects.filter( - partner_program_id__in=program_ids, - project_id__isnull=False, - ).values_list("project_id", flat=True) - project_ids = link_project_ids.union(profile_project_ids) +def legacy_program_projects(program_id): return Project.objects.filter( - id__in=project_ids, - is_public=False, + Q(program_links__partner_program_id=program_id) + | Q( + pk__in=PartnerProgramUserProfile.objects.filter( + partner_program_id=program_id, + ).values("project_id") + ), draft=False, - ).update(is_public=True) + is_public=False, + ) -def solution_publication_filter(now): - return dict( - solution_storage_mode="solution", - publish_projects_after_finish=True, - datetime_finished__lte=now, +@transaction.atomic +def publish_legacy_program(program_id, *, now): + program = ( + PartnerProgram.objects.select_for_update() + .filter(pk=program_id, **publication_filter(now, "legacy_project")) + .first() ) + if program is None: + return 0 + return legacy_program_projects(program_id).update(is_public=True) + + +def solution_publication_filter(now): + return publication_filter(now, "solution") + + +def unpublished_solutions(program): + solutions = ProgramSolution.objects.filter(program=program, published_at__isnull=True) + if program.is_competitive: + # Несданные конкурсные черновики остаются закрытыми. + solutions = solutions.filter(status="submitted") + return solutions @transaction.atomic @@ -55,22 +67,60 @@ def publish_solution_program(program_id, *, now): ) if program is None: return 0 - solutions = ProgramSolution.objects.filter(program=program, published_at__isnull=True) - if program.is_competitive: - # Независимый согласованный путь сданных работ. Вопрос о публикации - # несданных конкурсных черновиков остается открытым. - solutions = solutions.filter(status="submitted") - return solutions.update(published_at=now) + return unpublished_solutions(program).update(published_at=now) + + +def publish_program_if_due(program, *, now=None): + """Recheck eligibility under the program lock, without waiting for cron. + + Closed programs and repeat reads avoid the lock/write. Only server publication + markers change; existing markers are never revoked. + """ + now = timezone.now() if now is None else now + if ( + program.draft + or not program.publish_projects_after_finish + or program.datetime_finished > now + ): + return 0 + if program.solution_storage_mode == "solution": + if not unpublished_solutions(program).exists(): + return 0 + return publish_solution_program(program.pk, now=now) + if not legacy_program_projects(program.pk).exists(): + return 0 + return publish_legacy_program(program.pk, now=now) + + +def publish_project_if_due(project_id): + """Direct legacy links work without visiting the program first.""" + now = timezone.now() + programs = PartnerProgram.objects.filter( + Q(program_projects__project_id=project_id) + | Q( + pk__in=PartnerProgramUserProfile.objects.filter( + project_id=project_id, + ).values("partner_program_id") + ), + **publication_filter(now, "legacy_project"), + ).distinct().order_by("pk") + return sum(publish_program_if_due(program, now=now) for program in programs) + + +def publish_finished_program_projects(now=None) -> int: + now = timezone.now() if now is None else now + programs = PartnerProgram.objects.filter( + **publication_filter(now, "legacy_project") + ).order_by("pk") + return sum(publish_program_if_due(program, now=now) for program in programs.iterator()) def publish_finished_program_solutions(now=None) -> int: now = timezone.now() if now is None else now - program_ids = ( + programs = ( PartnerProgram.objects.filter(**solution_publication_filter(now)) .order_by("pk") - .values_list("pk", flat=True) ) return sum( - publish_solution_program(program_id, now=now) - for program_id in program_ids.iterator() + publish_program_if_due(program, now=now) for program in programs.iterator() ) diff --git a/partner_programs/solution_publication_api.py b/partner_programs/solution_publication_api.py index 4b7470f4..43e66fbc 100644 --- a/partner_programs/solution_publication_api.py +++ b/partner_programs/solution_publication_api.py @@ -1,6 +1,7 @@ """Анонимное чтение allowlist опубликованных материалов решения.""" from django.http import Http404 +from partner_programs.services.publishing import publish_program_if_due from rest_framework import serializers from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -31,7 +32,7 @@ def finalize_response(self, request, response, *args, **kwargs): def program(self, pk): program = program_for(pk) - if program.solution_storage_mode != "solution": + if program.draft or program.solution_storage_mode != "solution": raise Http404 return program @@ -42,6 +43,7 @@ def get(self, request, pk): query = PublishedSolutionQuery(data=request.query_params) query.is_valid(raise_exception=True) data = query.validated_data + publish_program_if_due(program) rows = published_solutions(program, data["search"]) count, limit, offset = rows.count(), data["limit"], data["offset"] stop = offset + limit @@ -69,6 +71,7 @@ def get(self, request, pk, solution_id): query.is_valid(raise_exception=True) if not 0 < solution_id <= MAX_ID: raise Http404 + publish_program_if_due(program) solution = ( published_solutions(program, with_answers=True).filter(pk=solution_id).first() ) diff --git a/partner_programs/tests/test_dev_publication_audit.py b/partner_programs/tests/test_dev_publication_audit.py new file mode 100644 index 00000000..71e9e355 --- /dev/null +++ b/partner_programs/tests/test_dev_publication_audit.py @@ -0,0 +1,56 @@ +import os +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, +) +from partner_programs.tests.test_solution_publication import PublicationFixture +from scripts.verify_program_publication_dev import main + + +class DevPublicationAuditTests(PublicationFixture, TestCase): + def test_all_program_audit_checks_eligible_closed_and_draft_without_business_edits(self): + self.seed_publication() + legacy = create_partner_program( + publish_projects_after_finish=True, + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + project = create_project(is_public=False) + draft = create_project(is_public=False, draft=True) + create_program_project(legacy, project=project) + create_program_project(legacy, project=draft) + create_program_member(legacy, project=project) + for mode in ["legacy_project", "solution"]: + for changes in [ + dict(publish_projects_after_finish=False), + dict(datetime_finished=timezone.now() + timezone.timedelta(days=1)), + dict(draft=True), + ]: + program = create_partner_program( + solution_storage_mode=mode, + publish_projects_after_finish=True, + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + if mode == "solution": + self.other_solution(program) + else: + create_program_project(program, project=create_project(is_public=False)) + for key, value in changes.items(): + setattr(program, key, value) + program.save() + with patch.dict(os.environ, VERIFY_EXPECTED_API_SHA="a" * 40, VERIFY_PHASE="verify_publication"): + with patch("builtins.print") as output: + main() + self.assertIn('"non_publication_data_unchanged": true', output.call_args.args[0]) + project.refresh_from_db() + draft.refresh_from_db() + self.solution.refresh_from_db() + self.assertTrue(project.is_public) + self.assertFalse(draft.is_public) + self.assertIsNotNone(self.solution.published_at) diff --git a/partner_programs/tests/test_published_projects_api.py b/partner_programs/tests/test_published_projects_api.py index d968077c..563830d3 100644 --- a/partner_programs/tests/test_published_projects_api.py +++ b/partner_programs/tests/test_published_projects_api.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.test import TestCase from django.utils import timezone from rest_framework.test import APIClient @@ -31,16 +33,51 @@ def setUp(self): def read(self, path=None, **kwargs): return self.client.get(path or self.url, secure=True, **kwargs) - def test_anonymous_reader_only_returns_published_non_draft_projects(self): + def test_anonymous_reader_publishes_due_non_drafts_without_waiting_for_task(self): response = self.read() self.assertEqual(response.status_code, 200) self.assertEqual( - [row["id"] for row in response.data["results"]], [self.public.pk] + [row["id"] for row in response.data["results"]], + [self.public.pk, self.private.pk], ) self.assertNotIn("program_data", response.data["results"][0]) self.assertNotIn("scores", response.data["results"][0]) self.private.refresh_from_db() - self.assertFalse(self.private.is_public) # GET does not publish. + self.assertTrue(self.private.is_public) + self.assertEqual(response["Cache-Control"], "no-store") + + def test_publication_happens_at_exact_deadline_and_on_late_flag_enable(self): + deadline = timezone.now() + self.program.datetime_finished = deadline + self.program.save(update_fields=["datetime_finished"]) + with patch("django.utils.timezone.now", return_value=deadline - timezone.timedelta(microseconds=1)): + self.assertEqual(self.read().status_code, 404) + self.private.refresh_from_db() + self.assertFalse(self.private.is_public) + with patch("django.utils.timezone.now", return_value=deadline): + self.assertEqual(self.read().data["count"], 2) + late = create_project(is_public=False) + create_program_member(self.program, project=late) + self.program.publish_projects_after_finish = False + self.program.save(update_fields=["publish_projects_after_finish"]) + self.assertEqual(self.read().status_code, 404) + late.refresh_from_db() + self.assertFalse(late.is_public) + self.program.publish_projects_after_finish = True + self.program.save(update_fields=["publish_projects_after_finish"]) + self.assertEqual(self.read().data["count"], 3) + late.refresh_from_db() + self.assertTrue(late.is_public) + + def test_program_page_and_direct_project_link_publish_without_list_visit(self): + self.assertEqual(self.read(f"/projects/{self.private.pk}/").status_code, 200) + self.private.refresh_from_db() + self.assertTrue(self.private.is_public) + late = create_project(is_public=False) + create_program_member(self.program, project=late) + self.assertEqual(self.read(f"/programs/{self.program.pk}/").status_code, 200) + late.refresh_from_db() + self.assertTrue(late.is_public) def test_member_and_unregistered_detail_expose_server_capability(self): for registered in (False, True): @@ -65,6 +102,8 @@ def test_flag_future_draft_and_solution_mode_do_not_open_legacy_reader(self): publish_projects_after_finish=True, datetime_finished=timezone.now() - timezone.timedelta(days=1), ) + hidden = create_project(is_public=False) + PartnerProgramProject.objects.create(partner_program=program, project=hidden) for key, value in values.items(): setattr(program, key, value) program.save() @@ -72,6 +111,10 @@ def test_flag_future_draft_and_solution_mode_do_not_open_legacy_reader(self): self.read(f"/programs/{program.pk}/published-projects/").status_code, 404, ) + self.assertEqual(self.read(f"/programs/{program.pk}/").status_code, 200) + self.assertIn(self.read(f"/projects/{hidden.pk}/").status_code, (401, 403)) + hidden.refresh_from_db() + self.assertFalse(hidden.is_public) def test_profile_source_search_pagination_and_duplicate_links(self): create_program_member(self.program, user=self.user, project=self.public) diff --git a/partner_programs/tests/test_solution_competitive_publication.py b/partner_programs/tests/test_solution_competitive_publication.py index dd3ae02a..d1baa5b4 100644 --- a/partner_programs/tests/test_solution_competitive_publication.py +++ b/partner_programs/tests/test_solution_competitive_publication.py @@ -235,17 +235,17 @@ def test_flag_date_boundary_repeat_and_disabled_creation_keep_first_marker(self) self.solution.refresh_from_db() self.assertEqual(self.solution.published_at, self.now) - def test_get_and_first_submission_do_not_publish_without_job(self): + def test_first_get_after_submission_publishes_without_job_and_keeps_drafts_closed(self): self.assertEqual(self.read()["count"], 0) + draft = self.other_solution() self.submit_ready() - self.assertEqual(self.read()["count"], 0) - self.assertEqual( - APIClient().get(self.public_url + f"{self.solution.pk}/").status_code, 404 - ) + self.read(self.solution) self.solution.refresh_from_db() - self.assertIsNone(self.solution.published_at) - self.assertEqual(self.publish(), 1) + self.assertIsNotNone(self.solution.published_at) + self.assertEqual(self.publish(), 0) self.assertEqual(self.read()["count"], 1) + draft.refresh_from_db() + self.assertIsNone(draft.published_at) def test_public_reads_never_query_or_expose_private_scores_and_assignments(self): self.submit_ready() diff --git a/partner_programs/tests/test_solution_publication.py b/partner_programs/tests/test_solution_publication.py index ede240ec..3b7aec99 100644 --- a/partner_programs/tests/test_solution_publication.py +++ b/partner_programs/tests/test_solution_publication.py @@ -4,6 +4,7 @@ from pathlib import Path from threading import Barrier from unittest import skipUnless +from unittest.mock import patch from django.contrib import admin from django.db import close_old_connections, connection @@ -168,12 +169,43 @@ def test_partial_and_empty_materials_are_publishable_saved_work(self): self.assertTrue(any(field["required"] for field in data["fields"])) self.assertEqual(data["team"]["accepted_count"], 1) - def test_get_is_read_only_and_does_not_publish_an_eligible_work(self): - self.assertEqual(self.read()["count"], 0) - response = APIClient().get(self.public_url + f"{self.solution.pk}/") - self.assertEqual(response.status_code, 404) + def test_first_public_detail_publishes_eligible_work_without_periodic_task(self): + before = ProgramSolution.objects.filter(pk=self.solution.pk).values().get() + with patch("django.utils.timezone.now", return_value=self.now): + self.read(self.solution) + self.solution.refresh_from_db() + self.assertEqual(self.solution.published_at, self.now) + after = ProgramSolution.objects.filter(pk=self.solution.pk).values().get() + self.assertEqual(after, dict(before, published_at=self.now)) + self.assertEqual(self.read()["count"], 1) + + def test_first_list_at_deadline_and_late_flag_enable_are_immediate(self): + PartnerProgram.objects.filter(pk=self.program.pk).update(datetime_finished=self.now) + with patch("django.utils.timezone.now", return_value=self.now - timezone.timedelta(microseconds=1)): + self.assertEqual(self.read()["count"], 0) + with patch("django.utils.timezone.now", return_value=self.now): + self.assertEqual(self.read()["count"], 1) + second = self.other_solution() + PartnerProgram.objects.filter(pk=self.program.pk).update(publish_projects_after_finish=False) + self.assertEqual(self.read()["count"], 1) + second.refresh_from_db() + self.assertIsNone(second.published_at) + PartnerProgram.objects.filter(pk=self.program.pk).update(publish_projects_after_finish=True) + self.assertEqual(self.read()["count"], 2) + second.refresh_from_db() + self.assertIsNotNone(second.published_at) + + def test_program_page_publishes_and_draft_program_stays_private(self): + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=True) + self.assertEqual(APIClient().get(self.public_url).status_code, 404) + self.assertEqual(APIClient().get(f"/programs/{self.program.pk}/").status_code, 200) + self.assertEqual(self.publish(), 0) self.solution.refresh_from_db() self.assertIsNone(self.solution.published_at) + PartnerProgram.objects.filter(pk=self.program.pk).update(draft=False) + self.assertEqual(APIClient().get(f"/programs/{self.program.pk}/").status_code, 200) + self.solution.refresh_from_db() + self.assertIsNotNone(self.solution.published_at) def test_public_materials_keep_zero_false_files_and_only_accepted_people(self): member, pending = create_user(), create_user() @@ -319,6 +351,9 @@ def test_unpublished_foreign_unknown_unsafe_are_hidden_for_every_role(self): unpublished = self.other_solution() self.publish() later = self.other_solution() + PartnerProgram.objects.filter(pk=self.program.pk).update( + publish_projects_after_finish=False + ) for actor in [None, self.captain, self.manager, create_user()]: for identifier in [later.pk, MAX_ID, MAX_ID + 1, 0]: response = call(actor, "get", self.public_url + f"{identifier}/") diff --git a/partner_programs/views.py b/partner_programs/views.py index 7428b2dd..bc14870b 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -30,6 +30,7 @@ can_manage_program, ) from partner_programs.selectors import get_current_program_application +from partner_programs.services.publishing import publish_program_if_due from partner_programs.serializers import ( PartnerProgramDataSchemaSerializer, PartnerProgramFieldSerializer, @@ -155,6 +156,7 @@ class PartnerProgramDetail(generics.RetrieveAPIView): def get(self, request, *args, **kwargs): program = self.get_object() + publish_program_if_due(program) program_user_profile = None if request.user.is_authenticated: program_user_profile = ( diff --git a/projects/permissions.py b/projects/permissions.py index 47197688..a8c4a855 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -64,7 +64,7 @@ def has_permission(self, request, view): return True try: - project = Project.objects.only("id", "leader_id", "is_public").get( + project = Project.objects.only("id", "leader_id", "is_public", "draft").get( pk=project_id ) except Project.DoesNotExist: @@ -84,6 +84,13 @@ def has_object_permission(self, request, view, obj): def _can_view_project(self, request, project: Project) -> bool: if project.is_public: return True + if request.method in SAFE_METHODS and not project.draft: + from partner_programs.services.publishing import publish_project_if_due + + publish_project_if_due(project.pk) + project.refresh_from_db(fields=["is_public"]) + if project.is_public: + return True return has_project_read_involvement(getattr(request, "user", None), project) diff --git a/scripts/verify_program_publication_dev.py b/scripts/verify_program_publication_dev.py new file mode 100644 index 00000000..da2efbb2 --- /dev/null +++ b/scripts/verify_program_publication_dev.py @@ -0,0 +1,187 @@ +"""DEV acceptance: every existing program, only intended publication marker writes. + +Unlike the synthetic rollback acceptance, successful publication is committed. +Every business row and all non-publication fields must remain exactly unchanged. +No emails, registrations, submissions, program edits or fixture creation. +""" + +import hashlib +import json +import os +import re +import sys +from unittest.mock import patch + + +def main(): + sys.path.insert(0, os.getcwd()) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") + import django + + django.setup() + from django.apps import apps + from django.db import connection, transaction + from django.test import override_settings + from django.utils import timezone + from rest_framework.test import APIClient + + from partner_programs.models import PartnerProgram, PartnerProgramUserProfile + from partner_programs.solution_models import ProgramSolution + from partner_programs.services.publishing import legacy_program_projects + from projects.models import Project + + assert connection.vendor == "postgresql" + assert re.fullmatch(r"[0-9a-f]{40}", os.environ.get("VERIFY_EXPECTED_API_SHA", "")) + assert os.environ.get("VERIFY_PHASE") == "verify_publication" + now = timezone.now() + models = [ + model for model in apps.get_models(include_auto_created=True) + if model._meta.app_label in {"partner_programs", "projects", "project_rates"} + and not model._meta.proxy + ] + + def stable_value(value): + if isinstance(value, (memoryview, bytes, bytearray)): + return {"binary_hex": bytes(value).hex()} + return str(value) + + def snapshot(): + hashes = {} + for model in models: + fields = [field.attname for field in model._meta.concrete_fields] + if model is Project: + fields.remove("is_public") + if model is ProgramSolution: + fields.remove("published_at") + rows = list(model._base_manager.order_by("pk").values(*fields)) + payload = json.dumps(rows, sort_keys=True, default=stable_value).encode() + hashes[model._meta.label] = dict(count=len(rows), hash=hashlib.sha256(payload).hexdigest()) + return hashes + + def linked_projects(program): + from django.db.models import Q + + return Project.objects.filter( + Q(program_links__partner_program=program) + | Q(pk__in=PartnerProgramUserProfile.objects.filter( + partner_program=program, + ).values("project_id")), + draft=False, + ).distinct() + + client = APIClient() + requests, results = [], [] + + def read(path, expected=200): + response = client.get(path, secure=True, HTTP_HOST="dev.procollab.ru") + requests.append(dict(path=path, status=response.status_code)) + assert response.status_code == expected, (path, response.status_code, expected) + return getattr(response, "data", None) + + def all_ids(path, kind): + ids, offset, expected_count = [], 0, None + while True: + page = read(f"{path}?limit=100&offset={offset}") + if expected_count is None: + expected_count = page["count"] + assert expected_count == page["count"] + rows = page["results"] + ids.extend(row["id"] if kind == "project" else row["application"]["solution_id"] for row in rows) + offset += len(rows) + if offset >= expected_count: + break + assert rows, (path, "pagination did not advance") + assert len(ids) == len(set(ids)) == expected_count + return sorted(ids) + + with override_settings( + ALLOWED_HOSTS=["dev.procollab.ru", "testserver"], + CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, + ), patch("django.utils.timezone.now", return_value=now), patch("core.log.middleware._add_logger_handler"): + with transaction.atomic(): + before = snapshot() + projects_before = dict(Project.objects.values_list("pk", "is_public")) + solutions_before = dict(ProgramSolution.all_objects.values_list("pk", "published_at")) + expected_projects, expected_solutions = projects_before.copy(), solutions_before.copy() + programs = list(PartnerProgram.objects.order_by("pk")) + for program in programs: + due = bool( + not program.draft and program.publish_projects_after_finish + and program.datetime_finished <= now + ) + if due and program.solution_storage_mode == "legacy_project": + for pk in legacy_program_projects(program.pk).values_list("pk", flat=True): + expected_projects[pk] = True + elif due and program.solution_storage_mode == "solution": + rows = ProgramSolution.objects.filter(program=program, published_at__isnull=True) + if program.is_competitive: + rows = rows.filter(status="submitted") + for pk in rows.values_list("pk", flat=True): + expected_solutions[pk] = now + + for program in programs: + root = f"/programs/{program.pk}/" + due = bool( + not program.draft and program.publish_projects_after_finish + and program.datetime_finished <= now + ) + # The ordinary program page must be sufficient; no task/service call here. + read(root) + if program.solution_storage_mode == "legacy_project": + path = root + "published-projects/" + if due: + actual = all_ids(path, "project") + expected = sorted(linked_projects(program).filter(is_public=True).values_list("pk", flat=True)) + assert actual == expected, (program.pk, actual, expected) + for pk in actual: + read(f"/projects/{pk}/") + else: + read(path, 404) + actual = [] + read(root + "published-solutions/", 404) + else: + path = root + "published-solutions/" + read(root + "published-projects/", 404) + if program.draft: + read(path, 404) + actual = [] + else: + actual = all_ids(path, "solution") + rows = ProgramSolution.objects.filter(program=program, published_at__isnull=False) + if program.is_competitive: + rows = rows.filter(status="submitted") + assert actual == sorted(rows.values_list("pk", flat=True)), program.pk + for pk in actual: + material = read(path + f"{pk}/") + assert set(material) == {"application", "published_at", "fields", "answers", "team"} + assert material["application"]["program_id"] == program.pk + assert material["application"]["solution_id"] == pk + read(root + "projects/", 401) + results.append(dict( + program_id=program.pk, mode=program.solution_storage_mode, + draft=program.draft, publication_enabled=program.publish_projects_after_finish, + finished=program.datetime_finished <= now, published_count=len(actual), + )) + + assert dict(Project.objects.values_list("pk", "is_public")) == expected_projects + assert dict(ProgramSolution.all_objects.values_list("pk", "published_at")) == expected_solutions + after = snapshot() + assert after == before, "Non-publication data changed" + # Repeat every program request; original publication timestamps must survive. + for program in programs: + read(f"/programs/{program.pk}/") + assert dict(Project.objects.values_list("pk", "is_public")) == expected_projects + assert dict(ProgramSolution.all_objects.values_list("pk", "published_at")) == expected_solutions + assert snapshot() == before + + print("PROGRAM_PUBLICATION_ACCEPTANCE=" + json.dumps(dict( # noqa: T201 + source_sha=os.environ["VERIFY_EXPECTED_API_SHA"], checked_at=now.isoformat(), + programs=results, requests=requests, retained_models=before, + published_project_ids=[pk for pk in expected_projects if expected_projects[pk] != projects_before[pk]], + published_solution_ids=[pk for pk in expected_solutions if expected_solutions[pk] != solutions_before[pk]], + non_publication_data_unchanged=after == before, repeat_requests_idempotent=True, + ), sort_keys=True)) + + +if __name__ == "__main__": + main() From c8f7e42a08fd1c4160c72066d74c6c47fe8e7e00 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 12:50:13 +0300 Subject: [PATCH 2/3] test(programs): verify immediate publication in dev rollback acceptance --- scripts/verify_program_solutions_dev.py | 32 +++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/verify_program_solutions_dev.py b/scripts/verify_program_solutions_dev.py index e1b03fd7..0c62dd45 100644 --- a/scripts/verify_program_solutions_dev.py +++ b/scripts/verify_program_solutions_dev.py @@ -31,8 +31,10 @@ def main(): from rest_framework.test import APIClient from partner_programs.models import PartnerProgram, PartnerProgramField from partner_programs.solution_models import ProgramSolution - from partner_programs.tests.helpers import create_user, create_partner_program, create_program_member - from partner_programs.services.publishing import publish_solution_program + from partner_programs.tests.helpers import ( + create_user, create_partner_program, create_program_member, + create_program_project, create_project, + ) from project_rates.tests.helpers import create_rate_expert, create_rate_criteria from project_rates.criteria_policy import revision @@ -325,12 +327,13 @@ def request(actor, method, path, body=None, expected=200, binary=False): binary=True, ) assert export.startswith(b"PK") - assert publish_solution_program(program.pk, now=now) == 0 + assert request(None, "get", base + "published-solutions/")["count"] == 0 + assert ProgramSolution.objects.get(pk=solution["id"]).published_at is None PartnerProgram.objects.filter(pk=program.pk, tag__startswith=prefix).update( datetime_finished=now - timezone.timedelta(seconds=1) ) - assert publish_solution_program(program.pk, now=now) == 1 public = request(None, "get", base + f"published-solutions/{solution['id']}/") + assert ProgramSolution.objects.get(pk=solution["id"]).published_at is not None assert public["team"]["accepted_count"] == 2 public_keys = json.dumps(public, default=str) assert all( @@ -355,12 +358,31 @@ def request(actor, method, path, body=None, expected=200, binary=False): checks.append( template + (":competitive" if competitive else ":noncompetitive") - + ":create-team-save-permissions-analytics-xlsx-publication-creation-stop" + + ":create-team-save-permissions-analytics-xlsx-immediate-publication-without-job-creation-stop" ) programs.append( {"template": template, "competitive": competitive, "transientProgramId": program.pk} ) assert apps.get_model("projects.Project").objects.count() == project_count + legacy = create_partner_program( + name=prefix, tag=prefix + "-legacy-immediate", + publish_projects_after_finish=True, + datetime_finished=now + timezone.timedelta(days=1), + ) + work = create_project(is_public=False) + create_program_project(legacy, project=work) + before_work = apps.get_model("projects.Project").objects.filter(pk=work.pk).values().get() + path = f"/programs/{legacy.pk}/published-projects/" + request(None, "get", path, expected=404) + PartnerProgram.objects.filter(pk=legacy.pk, tag__startswith=prefix).update( + datetime_finished=now - timezone.timedelta(seconds=1) + ) + page = request(None, "get", path) + assert page["count"] == 1 and page["results"][0]["id"] == work.pk + after_work = apps.get_model("projects.Project").objects.filter(pk=work.pk).values().get() + assert after_work == dict(before_work, is_public=True) + request(None, "get", f"/projects/{work.pk}/") + checks.append("legacy-immediate-publication-without-job-only-is-public-changed") for old in PartnerProgram.objects.filter(pk__in=legacy_before).order_by("pk"): request(None, "get", f"/programs/{old.pk}/") manager = old.managers.first() From 6b4bc1433b513a10ac33f2811fa6ae9783609e2b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 27 Oct 2026 12:51:49 +0300 Subject: [PATCH 3/3] test(programs): scope dev database audit to PostgreSQL --- partner_programs/tests/test_dev_publication_audit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/partner_programs/tests/test_dev_publication_audit.py b/partner_programs/tests/test_dev_publication_audit.py index 71e9e355..ad0ca1d2 100644 --- a/partner_programs/tests/test_dev_publication_audit.py +++ b/partner_programs/tests/test_dev_publication_audit.py @@ -1,6 +1,8 @@ import os +from unittest import skipUnless from unittest.mock import patch +from django.db import connection from django.test import TestCase from django.utils import timezone @@ -14,6 +16,7 @@ from scripts.verify_program_publication_dev import main +@skipUnless(connection.vendor == "postgresql", "DEV audit requires PostgreSQL") class DevPublicationAuditTests(PublicationFixture, TestCase): def test_all_program_audit_checks_eligible_closed_and_draft_without_business_edits(self): self.seed_publication()