Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/dev-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions docs/contracts/program-solutions-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 и права записи не меняются.
4 changes: 2 additions & 2 deletions docs/contracts/program-solutions-v1/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}/": {
Expand Down Expand Up @@ -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/": {
Expand Down
8 changes: 7 additions & 1 deletion partner_programs/published_projects_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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)
Expand Down
124 changes: 87 additions & 37 deletions partner_programs/services/publishing.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
)
5 changes: 4 additions & 1 deletion partner_programs/solution_publication_api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
)
Expand Down
59 changes: 59 additions & 0 deletions partner_programs/tests/test_dev_publication_audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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

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


@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()
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)
Loading
Loading