From a88345a3bd99ccf68f80a232d8549d458515eb00 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 26 Oct 2026 14:52:20 +0300 Subject: [PATCH] test(dev): add protected acceptance for program solutions --- .github/workflows/dev-ci.yml | 80 +++- scripts/prepare_program_solutions_browser.py | 211 ++++++++++ scripts/verify_program_solutions_dev.py | 407 +++++++++++++++++++ 3 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 scripts/prepare_program_solutions_browser.py create mode 100644 scripts/verify_program_solutions_dev.py diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index c2afc0bd..bc5cb08b 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -5,6 +5,20 @@ on: branches: - dev workflow_dispatch: + inputs: + operation: + description: "DEV operation" + type: choice + default: deploy + options: [deploy, verify_solution_rollback, prepare_solution_browser, finalize_solution_browser] + fixture_key: + description: "Unique acceptance fixture key" + default: release-20261026 + type: string + manager_user_id: + description: "Existing user who owns the new QA programs" + default: "28" + type: string permissions: contents: read @@ -16,7 +30,7 @@ concurrency: jobs: deploy: name: Deploy - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/dev' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'deploy' || inputs.operation == '') environment: dev runs-on: ubuntu-latest steps: @@ -356,3 +370,67 @@ jobs: exit 1 fi run: node scripts/dev_ci_ssh.cjs + solution-acceptance: + name: Solution acceptance + if: github.ref == 'refs/heads/dev' && github.event_name == 'workflow_dispatch' && inputs.operation != 'deploy' && inputs.operation != '' + environment: dev + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Install DEV SSH transport + run: npm install --prefix "$RUNNER_TEMP/dev-verification-ssh" --ignore-scripts --no-package-lock --no-audit --no-fund ssh2@1.16.0 + - name: Verify deployed source and run bounded acceptance + env: + NODE_PATH: ${{ runner.temp }}/dev-verification-ssh/node_modules + DEV_SERVER_HOST: ${{ secrets.DEV_SERVER_HOST }} + DEV_SERVER_USER: ${{ secrets.DEV_SERVER_USER }} + DEV_SERVER_PASSWORD: ${{ secrets.DEV_SERVER_PASSWORD }} + DEV_SSH_HOST_KEY_SHA256: ${{ vars.DEV_SSH_HOST_KEY_SHA256 }} + VERIFY_OPERATION: ${{ inputs.operation }} + VERIFY_FIXTURE_KEY: ${{ inputs.fixture_key }} + VERIFY_MANAGER_ID: ${{ inputs.manager_user_id }} + run: | + node <<'NODE' + const fs = require('node:fs'); + const { deploy } = require('./scripts/dev_ci_ssh.cjs'); + const sha = process.env.GITHUB_SHA; + 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' }; + 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 source = fs.readFileSync(file, 'utf8'); + const script = [ + 'set +x', + 'set -eu', + 'cd /root/api', + 'test "$(git rev-parse HEAD)" = "' + sha + '"', + 'web="$(docker compose -f docker-compose.dev-ci.yml ps -q web)"', + 'worker="$(docker compose -f docker-compose.dev-ci.yml ps -q celerys)"', + 'test -n "$web" && test -n "$worker"', + 'web_image="$(docker inspect -f \'{{.Config.Image}}\' "$web")"', + 'worker_image="$(docker inspect -f \'{{.Config.Image}}\' "$worker")"', + 'case "$web_image" in procollab-dev-api:' + sha + '-*) ;; *) exit 31 ;; esac', + 'test "$web_image" = "$worker_image"', + 'test "$(docker inspect -f \'{{.Image}}\' "$web")" = "$(docker inspect -f \'{{.Image}}\' "$worker")"', + 'echo "VERIFIED_DEV_RUNTIME_REF=' + sha + '"', + 'docker exec "$web" python manage.py migrate --check', + 'docker exec -e VERIFY_EXPECTED_API_SHA=' + sha + ' -e VERIFY_EXPECTED_LEGACY_IDS=1,2,3,4,5,6,7,8,9,38,39 -e VERIFY_PHASE=' + phase + ' -e VERIFY_FIXTURE_KEY=' + key + ' -e VERIFY_MANAGER_ID=' + manager + ' -i "$web" python - <<\'SOLUTION_ACCEPTANCE_PY\'', + source, + 'SOLUTION_ACCEPTANCE_PY' + ].join('\n') + '\n'; + deploy({ + host: process.env.DEV_SERVER_HOST, + username: process.env.DEV_SERVER_USER, + password: process.env.DEV_SERVER_PASSWORD, + hostFingerprint: process.env.DEV_SSH_HOST_KEY_SHA256, + script, + timeoutMs: 9 * 60 * 1000 + }).catch(() => { console.error('DEV solution acceptance failed; inspect masked output.'); process.exitCode = 1; }); + NODE diff --git a/scripts/prepare_program_solutions_browser.py b/scripts/prepare_program_solutions_browser.py new file mode 100644 index 00000000..95563e76 --- /dev/null +++ b/scripts/prepare_program_solutions_browser.py @@ -0,0 +1,211 @@ +"""Создает только собственные QA-программы; завершение отключает новое создание.""" +import hashlib +import io +import json +import os +import re +import sys +from pathlib import Path +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.contrib.auth import get_user_model + from django.core.management import call_command + from django.db import transaction + from django.test import override_settings + from django.utils import timezone + from partner_programs.models import PartnerProgram, PartnerProgramField + from partner_programs.solution_models import ProgramSolution + from partner_programs.services.publishing import publish_solution_program + from partner_programs.tests.helpers import create_partner_program, create_program_member + + key = os.environ["VERIFY_FIXTURE_KEY"] + assert re.fullmatch(r"[a-z0-9-]{8,50}", key) + manager_id = int(os.environ["VERIFY_MANAGER_ID"]) + assert manager_id > 0 + phase = os.environ["VERIFY_PHASE"] + assert phase in ("prepare_browser", "finalize_browser") + prefix = "qa-solution-browser-" + key + receipt = Path("/procollab/log") / (prefix + ".json") + if os.environ.get("DJANGO_SETTINGS_MODULE") == "procollab.settings_ci": + receipt = Path(os.environ["TEMP"]) / (prefix + ".json") + manager = get_user_model().objects.get(pk=manager_id, is_active=True) + + def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() + + def rows(model, ids): + return list(model._base_manager.filter(pk__in=ids).order_by("pk").values()) + + protected_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 + ] + with override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend"), patch( + "celery.app.task.Task.apply_async" + ): + if phase == "prepare_browser": + assert not receipt.exists(), "Use a fresh fixture key; preparation must not be replayed." + assert not PartnerProgram.objects.filter(tag__startswith=prefix).exists() + baseline = {} + for model in protected_models: + ids = list(model._base_manager.order_by("pk").values_list("pk", flat=True)) + baseline[model._meta.label] = {"ids": ids, "hash": digest(rows(model, ids))} + result = { + "prefix": prefix, + "managerId": manager_id, + "programs": [], + "baseline": baseline, + "projectCount": apps.get_model("projects.Project").objects.count(), + "complete": False, + } + with transaction.atomic(): + for template, competitive, label in [ + ("case_championship", True, "Кейс-чемпионат"), + ("hackathon", True, "Хакатон"), + ("hackathon", False, "Неконкурсная программа"), + ]: + now = timezone.now() + program = create_partner_program( + name="[QA 26.10] " + label + " — решения", + tag=prefix + "-" + str(len(result["programs"]) + 1), + description="Отдельная программа приемки решений после merge. Старые программы не изменяются.", + solution_storage_mode="solution", + solution_template=template, + solution_creation_enabled=False, + is_competitive=competitive, + legacy_team_min_size=1, + legacy_team_max_size=4, + datetime_registration_ends=now + timezone.timedelta(days=7), + datetime_project_submission_ends=now + timezone.timedelta(days=7), + datetime_evaluation_ends=now + timezone.timedelta(days=8), + datetime_finished=now + timezone.timedelta(days=9), + publish_projects_after_finish=True, + ) + program.managers.add(manager) + create_program_member(program, user=manager) + fields = {} + definitions = [ + ("case", "select", "Кейс", "case"), + ("materials", "textarea", "Описание решения", "other"), + ("zero", "number", "Число для проверки", "other"), + ("boolean", "checkbox", "Дополнительная опция", "other"), + ] + if template == "hackathon": + definitions.append(("repository", "url", "Ссылка на репозиторий", "repository")) + for position, (name, kind, field_label, purpose) in enumerate(definitions): + fields[name] = PartnerProgramField.objects.create( + partner_program=program, + name=name, + label=field_label, + field_type=kind, + purpose=purpose, + position=position, + is_required=True, + show_filter=name == "case", + solution_options=["Город", "Образование"] if name == "case" else [], + ).pk + call_command( + "set_solution_creation", + program_id=program.pk, + enable=True, + apply=True, + stdout=io.StringIO(), + ) + result["programs"].append( + {"id": program.pk, "template": template, "competitive": competitive, "fields": fields} + ) + for model in protected_models: + entry = baseline[model._meta.label] + assert digest(rows(model, entry["ids"])) == entry["hash"] + receipt.write_text(json.dumps(result, ensure_ascii=False, default=str), encoding="utf8") + receipt.chmod(0o600) + sys.stdout.write( + "BROWSER_FIXTURES=" + + json.dumps( + { + "managerId": manager_id, + "key": key, + "programs": result["programs"], + "oldModelsUnchanged": len(baseline), + }, + ensure_ascii=False, + ) + + "\n" + ) + else: + assert receipt.exists(), "Preparation receipt missing." + result = json.loads(receipt.read_text(encoding="utf8")) + assert result["prefix"] == prefix and result["managerId"] == manager_id + assert not result["complete"], "Finalization must not be replayed." + completed = [] + with transaction.atomic(): + for item in result["programs"]: + program = PartnerProgram.objects.get( + pk=item["id"], tag__startswith=prefix, solution_storage_mode="solution" + ) + assert program.managers.filter(pk=manager_id).exists() + solution = ProgramSolution.objects.get(program=program, captain_id=manager_id) + assert solution.answers.count() >= 4 + assert solution.status == ("submitted" if item["competitive"] else "draft") + values = {answer.field_id: answer.value for answer in solution.answers.all()} + assert values[item["fields"]["zero"]] == 0 and values[item["fields"]["boolean"]] is False + if item["competitive"]: + assert len(solution.submission_snapshot["members"]) == 1 + call_command( + "set_solution_creation", + program_id=program.pk, + disable=True, + apply=True, + stdout=io.StringIO(), + ) + PartnerProgram.objects.filter(pk=program.pk, tag__startswith=prefix).update( + datetime_finished=timezone.now() - timezone.timedelta(seconds=1) + ) + assert publish_solution_program(program.pk, now=timezone.now()) == 1 + solution.refresh_from_db() + assert solution.published_at is not None + completed.append( + { + **item, + "solutionId": solution.pk, + "published": True, + "creationEnabled": False, + "status": solution.status, + } + ) + for model in protected_models: + entry = result["baseline"][model._meta.label] + assert digest(rows(model, entry["ids"])) == entry["hash"], model._meta.label + assert apps.get_model("projects.Project").objects.count() == result["projectCount"] + result["complete"] = True + result["completedPrograms"] = completed + receipt.write_text(json.dumps(result, ensure_ascii=False, default=str), encoding="utf8") + receipt.chmod(0o600) + sys.stdout.write( + "BROWSER_ACCEPTANCE=" + + json.dumps( + { + "complete": True, + "key": key, + "programs": completed, + "oldModelsUnchanged": len(result["baseline"]), + "newProjects": 0, + }, + ensure_ascii=False, + ) + + "\n" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_program_solutions_dev.py b/scripts/verify_program_solutions_dev.py new file mode 100644 index 00000000..e953a59a --- /dev/null +++ b/scripts/verify_program_solutions_dev.py @@ -0,0 +1,407 @@ +"""Приемка API на dev; новые записи всегда откатываются транзакцией.""" + + +def main(): + import hashlib + import io + import json + import os + import sys + from unittest.mock import patch + from uuid import uuid4 + + 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.contrib.auth import get_user_model + from django.core.management import call_command + from django.db import connection, transaction + from django.db.migrations.executor import MigrationExecutor + from django.test import override_settings + from django.utils import timezone + 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 project_rates.tests.helpers import create_rate_expert, create_rate_criteria + from project_rates.criteria_policy import revision + + assert connection.vendor == "postgresql" + assert ( + __import__("re").fullmatch(r"[0-9a-f]{40}", os.environ.get("VERIFY_EXPECTED_API_SHA", "")) + or os.environ.get("DJANGO_SETTINGS_MODULE") == "procollab.settings_ci" + ) + assert not MigrationExecutor(connection).migration_plan( + MigrationExecutor(connection).loader.graph.leaf_nodes() + ) + prefix = "postmerge-dev-20261026-" + uuid4().hex[:12] + checks, wire, programs = [], [], [] + + def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest() + + 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 retained(): + return { + model._meta.label: { + "count": model._base_manager.count(), + "hash": digest(list(model._base_manager.order_by("pk").values())), + } + for model in models + } + + baseline = retained() + legacy_ids = [ + int(value) for value in os.environ.get("VERIFY_EXPECTED_LEGACY_IDS", "").split(",") if value + ] + if legacy_ids: + assert list( + PartnerProgram.objects.filter(pk__in=legacy_ids, solution_storage_mode="legacy_project") + .order_by("pk") + .values_list("pk", flat=True) + ) == sorted(legacy_ids) + legacy_before = list( + PartnerProgram.objects.filter(solution_storage_mode="legacy_project") + .order_by("pk") + .values_list("pk", flat=True) + ) + project_count = apps.get_model("projects.Project").objects.count() + rollback_complete = False + + def request(actor, method, path, body=None, expected=200, binary=False): + client = APIClient() + if actor is not None: + client.force_authenticate(actor) + response = getattr(client, method)( + path, {} if body is None else body, format="json", secure=True, HTTP_HOST="dev.procollab.ru" + ) + wire.append({"method": method.upper(), "path": path, "status": response.status_code}) + assert response.status_code == expected, ( + method, + path, + response.status_code, + getattr(response, "data", None), + ) + return response.content if binary else response.data + + try: + with override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + ALLOWED_HOSTS=["testserver", "dev.procollab.ru"], + CACHES={ + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": prefix} + }, + CHANNEL_LAYERS={"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}, + ), patch("celery.app.task.Task.apply_async"), patch("core.log.middleware._add_logger_handler"): + with transaction.atomic(): + users = { + role: (create_rate_expert if role == "expert" else create_user)( + prefix=prefix + "-" + role + ) + for role in ["manager", "captain", "member", "pending", "outsider", "expert"] + } + get_user_model().objects.filter(pk__in=[user.pk for user in users.values()]).update( + is_active=True + ) + now = timezone.now() + for template, competitive in [ + ("case_championship", True), + ("hackathon", True), + ("hackathon", False), + ]: + program = create_partner_program( + name=prefix, + tag=prefix + "-" + template + str(competitive), + solution_storage_mode="solution", + solution_template=template, + solution_creation_enabled=False, + is_competitive=competitive, + is_distributed_evaluation=competitive, + required_evaluators=1 if competitive else None, + max_project_rates=3, + legacy_team_min_size=2, + legacy_team_max_size=4, + datetime_project_submission_ends=now + timezone.timedelta(days=2), + datetime_evaluation_ends=now + timezone.timedelta(days=3), + datetime_finished=now + timezone.timedelta(days=4), + publish_projects_after_finish=True, + ) + program.managers.add(users["manager"]) + users["expert"].expert.programs.add(program) + for role in ["captain", "member", "pending"]: + create_program_member( + program, user=users[role], data={"private_note": "private-registration"} + ) + fields = {} + definitions = [ + ("case", "select", "case"), + ("materials", "textarea", "other"), + ("zero", "number", "other"), + ("boolean", "checkbox", "other"), + ] + if template == "hackathon": + definitions.append(("repository", "url", "repository")) + for position, (name, kind, purpose) in enumerate(definitions): + fields[name] = PartnerProgramField.objects.create( + partner_program=program, + name=name, + label=name, + field_type=kind, + purpose=purpose, + position=position, + is_required=True, + show_filter=name == "case", + solution_options=["Case A", "Case B"] if name == "case" else [], + ).pk + if competitive: + create_rate_criteria(program, name="Quality", type="int", min_value=0, max_value=10) + create_rate_criteria(program, name="Verified", type="bool") + base = f"/programs/{program.pk}/" + blocked = request(users["captain"], "post", base + "solutions/", {}, 409) + assert blocked["code"] == "solution_creation_disabled" + call_command( + "set_solution_creation", + program_id=program.pk, + enable=True, + apply=True, + stdout=io.StringIO(), + ) + solution = request( + users["captain"], "post", base + "solutions/", {"name": "Postmerge acceptance"}, 201 + ) + root = base + f"solutions/{solution['id']}/" + invite = request( + users["captain"], + "post", + root + "invitations/", + { + "expected_solution_version": solution["version"], + "recipient_user_id": users["member"].pk, + "role": "Analyst", + }, + 201, + )["invitation"] + request( + users["member"], + "post", + f"/solution-invitations/{invite['id']}/accept/", + {"expected_version": invite["version"]}, + ) + solution = request(users["captain"], "get", root) + request( + users["captain"], + "post", + root + "invitations/", + { + "expected_solution_version": solution["version"], + "recipient_user_id": users["pending"].pk, + "role": "Designer", + }, + 201, + ) + solution = request(users["captain"], "get", root) + assert solution["team"]["accepted_count"] == 2 and solution["team"]["pending_count"] == 1 + answers = [ + {"field_id": fields[name], "value": value} + for name, value in [ + ("case", "Case A"), + ("materials", "Actual merged dev runtime"), + ("zero", 0), + ("boolean", False), + ] + ] + if "repository" in fields: + answers.append( + {"field_id": fields["repository"], "value": "https://example.org/postmerge"} + ) + solution = request( + users["captain"], + "patch", + root, + { + "expected_version": solution["version"], + "expected_form_revision": solution["form"]["revision"], + "answers": answers, + }, + ) + request(users["outsider"], "get", root, expected=404) + request( + users["member"], + "patch", + root, + { + "expected_version": solution["version"], + "expected_form_revision": solution["form"]["revision"], + "name": "Forbidden", + }, + 403, + ) + if competitive: + solution = request( + users["captain"], + "post", + root + "submit/", + { + "expected_version": solution["version"], + "expected_form_revision": solution["form"]["revision"], + }, + ) + assert ( + solution["submission"]["status"] == "submitted" + and solution["form"]["is_snapshot"] + and not solution["permissions"]["can_edit"] + ) + stored = ProgramSolution.objects.get(pk=solution["id"]) + assert len(stored.submission_snapshot["members"]) == 2 + options_url = ( + base + + f"management/solution-evaluation/experts/{users['expert'].expert.pk}/assignments/" + ) + options = request(users["manager"], "get", options_url) + request( + users["manager"], + "put", + options_url, + {"revision": options["revision"], "solution_ids": [solution["id"]]}, + ) + scores = [ + { + "criterion_id": criterion.pk, + "value": "0" + if criterion.type == "int" + else "False" + if criterion.type == "bool" + else "", + } + for criterion in program.criterias.all() + ] + rated = request( + users["expert"], + "post", + root + "evaluation/", + {"expected_criteria_revision": revision(program), "scores": scores}, + ) + assert rated["is_completed"] + result = request(users["manager"], "get", base + "management/solution-results/") + entry = next( + row + for group in result["groups"] + for row in group["solutions"] + if row["solution_id"] == solution["id"] + ) + assert ( + entry["score"] == 0 and entry["completed"] == 1 and entry["evaluation_complete"] + ) + checks.append(template + ":submit-snapshot-assignment-zero-false-results") + overview = request(users["manager"], "get", base + "management/solution-analytics/") + assert overview["summary"]["solutions"]["total"] == 1 + export = request( + users["manager"], + "get", + base + "management/solution-analytics/export/?kind=solutions", + binary=True, + ) + assert export.startswith(b"PK") + assert publish_solution_program(program.pk, now=now) == 0 + 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 public["team"]["accepted_count"] == 2 + public_keys = json.dumps(public, default=str) + assert all( + word not in public_keys + for word in ["private-registration", "pending_count", '"scores"', '"criteria"'] + ) + for expected_answer in answers: + assert expected_answer in public["answers"] or any( + row["field_id"] == expected_answer["field_id"] + and row["value"] == expected_answer["value"] + for row in public["answers"] + ) + call_command( + "set_solution_creation", + program_id=program.pk, + disable=True, + apply=True, + stdout=io.StringIO(), + ) + request(users["pending"], "post", base + "solutions/", {}, 409) + request(users["captain"], "get", root) + checks.append( + template + + (":competitive" if competitive else ":noncompetitive") + + ":create-team-save-permissions-analytics-xlsx-publication-creation-stop" + ) + programs.append( + {"template": template, "competitive": competitive, "transientProgramId": program.pk} + ) + assert apps.get_model("projects.Project").objects.count() == project_count + for old in PartnerProgram.objects.filter(pk__in=legacy_before).order_by("pk"): + request(None, "get", f"/programs/{old.pk}/") + manager = old.managers.first() + if manager is not None: + request(manager, "get", f"/programs/{old.pk}/management/projects/") + checks.append("all-existing-legacy-programs-detail-and-manager-projects") + transaction.set_rollback(True) + assert not PartnerProgram.objects.filter(tag__startswith=prefix).exists() + assert not get_user_model().objects.filter(email__startswith=prefix).exists() + rollback_complete = True + finally: + after = retained() + assert ( + after == baseline + ), "Existing model data changed; inspect concurrent writers before attributing any difference." + assert ( + list( + PartnerProgram.objects.filter(solution_storage_mode="legacy_project") + .order_by("pk") + .values_list("pk", flat=True) + ) + == legacy_before + ) + + assert rollback_complete + sys.stdout.write( + "POSTMERGE_DEV_EVIDENCE=" + + json.dumps( + { + "complete": True, + "expectedApiMerge": os.environ.get("VERIFY_EXPECTED_API_SHA"), + "scope": ( + "APIClient in actual dev web container" + if os.environ.get("VERIFY_EXPECTED_API_SHA") + else "local PostgreSQL preflight" + ) + + ", transaction rolled back", + "checks": checks, + "programs": programs, + "http": wire, + "legacyPrograms": len(legacy_before), + "premergePublicIdsRemainLegacy": legacy_ids, + "protectedModelHashes": baseline, + "retainedDigest": digest(baseline), + "legacyDataUnchanged": True, + "newProjectCount": 0, + "allSyntheticRowsRolledBack": True, + "externalMailAndTasksSuppressed": True, + }, + ensure_ascii=False, + ) + + "\n" + ) + + +if __name__ == "__main__": + main()