From aadc776f4ff44ede18227802823dcec5d41a9d90 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:32:45 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(tooling):=20JDG-11=20remainder=20+=20R?= =?UTF-8?q?O-6=20closure=20=E2=80=94=20validator=20runner,=20output=20gene?= =?UTF-8?q?ration,=20package=20import,=20archive=20axis=20[Wave=20C]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content identical to the reviewed branch tree (7 commits, squashed here) — re-committed through the git data API so every change carries the linked-account attribution the base-branch policy requires (same remedy as #237→#238). See PR #307 for the full description and the locally-run gate battery: backend pytest 1754 passed / 100% line+branch / zero warnings; vitest 653; tsc clean; eslint 0/0; next build; playwright 131+3 skipped. --- backend/problems/pdf_bundle.py | 190 +++++ backend/problems/serializers.py | 23 + backend/problems/test_pdf_bundle.py | 135 +++ backend/problems/test_tooling_closure.py | 830 +++++++++++++++++++ backend/problems/tooling.py | 641 +++++++++++++- backend/problems/urls.py | 23 + backend/problems/views.py | 36 + backend/public_api/views.py | 8 + e2e/lib/realStack.ts | 13 +- e2e/mock-api/server.mjs | 21 +- e2e/problems.spec.ts | 29 + public/locales/ar.json | 6 +- public/locales/bg.json | 6 +- public/locales/ca.json | 6 +- public/locales/de.json | 6 +- public/locales/el.json | 6 +- public/locales/en.json | 6 +- public/locales/es.json | 6 +- public/locales/fa.json | 6 +- public/locales/fr.json | 6 +- public/locales/hi.json | 6 +- public/locales/hr.json | 6 +- public/locales/hu.json | 6 +- public/locales/id.json | 6 +- public/locales/it.json | 6 +- public/locales/ja.json | 6 +- public/locales/kk.json | 6 +- public/locales/ko.json | 6 +- public/locales/pl.json | 6 +- public/locales/pt.json | 6 +- public/locales/ro.json | 6 +- public/locales/ru.json | 6 +- public/locales/sr-Latn.json | 6 +- public/locales/th.json | 6 +- public/locales/tr.json | 6 +- public/locales/uk.json | 6 +- public/locales/vi.json | 6 +- public/locales/zh-Hans.json | 6 +- public/locales/zh-Hant.json | 6 +- src/app/_lib/public-messages.ts | 5 + src/app/problems/ProblemsListClient.test.tsx | 92 ++ src/app/problems/ProblemsListClient.tsx | 143 +++- src/app/problems/_lib/problems-api.ts | 23 +- src/app/problems/page.tsx | 17 +- src/locales/ar.json | 6 +- src/locales/bg.json | 6 +- src/locales/ca.json | 6 +- src/locales/de.json | 6 +- src/locales/el.json | 6 +- src/locales/en.json | 6 +- src/locales/es.json | 6 +- src/locales/fa.json | 6 +- src/locales/fr.json | 6 +- src/locales/hi.json | 6 +- src/locales/hr.json | 6 +- src/locales/hu.json | 6 +- src/locales/id.json | 6 +- src/locales/it.json | 6 +- src/locales/ja.json | 6 +- src/locales/kk.json | 6 +- src/locales/ko.json | 6 +- src/locales/pl.json | 6 +- src/locales/pt.json | 6 +- src/locales/ro.json | 6 +- src/locales/ru.json | 6 +- src/locales/sr-Latn.json | 6 +- src/locales/th.json | 6 +- src/locales/tr.json | 6 +- src/locales/uk.json | 6 +- src/locales/vi.json | 6 +- src/locales/zh-Hans.json | 6 +- src/locales/zh-Hant.json | 6 +- src/types/index.ts | 5 + 73 files changed, 2498 insertions(+), 72 deletions(-) create mode 100644 backend/problems/pdf_bundle.py create mode 100644 backend/problems/test_pdf_bundle.py create mode 100644 backend/problems/test_tooling_closure.py diff --git a/backend/problems/pdf_bundle.py b/backend/problems/pdf_bundle.py new file mode 100644 index 00000000..a233a31c --- /dev/null +++ b/backend/problems/pdf_bundle.py @@ -0,0 +1,190 @@ +"""Offline study-bundle PDFs (competitive roadmap RO-6). + +A dependency-free PDF writer: the archive's "PDF bundle downloads for +offline study" is statement + sample tests in one printable file, and the +backend deps stay pinned (no reportlab — a minimal Type1-Helvetica PDF is +~80 lines and fully testable). + +Only latin-1 encodable text is written (Helvetica WinAnsi); characters +outside it are replaced with '?' so any statement degrades visibly rather +than crashing the download. +""" + +PAGE_WIDTH = 595.28 # A4, points +PAGE_HEIGHT = 841.89 +MARGIN = 56.0 +BODY_SIZE = 10.0 +HEADING_SIZE = 14.0 +LEADING = 14.0 +MAX_CHARS_PER_LINE = 88 # Helvetica 10pt on a ~483pt text column + + +def _escape(text: str) -> str: + return ( + text.replace("\\", "\\\\") + .replace("(", "\\(") + .replace(")", "\\)") + .encode("latin-1", errors="replace") + .decode("latin-1") + ) + + +def _wrap(text: str, width: int = MAX_CHARS_PER_LINE) -> list[str]: + """Character-wrap preserving existing line breaks (statements carry + their own layout; the bundle is for offline reading, not typesetting).""" + lines: list[str] = [] + for raw_line in text.splitlines() or [""]: + if len(raw_line) <= width: + lines.append(raw_line) + continue + words = raw_line.split(" ") + current = "" + for word in words: + candidate = f"{current} {word}".strip() + if len(candidate) > width and current: + lines.append(current) + current = word + else: + current = candidate + lines.append(current) + return lines or [""] + + +def build_pdf(sections: list[tuple[str, str]]) -> bytes: + """Render (heading, body) sections into a valid single-font PDF. + + Returns raw PDF bytes with one page per ~52 lines; the caller streams + them as ``application/pdf``. + """ + lines_per_page = int((PAGE_HEIGHT - 2 * MARGIN) // LEADING) - 1 + pages: list[list[str]] = [] + current: list[str] = [] + + def flush() -> None: + if current: + pages.append(current) + + for heading, body in sections: + if current and len(current) + 2 > lines_per_page: + flush() + current = [] + current.append(("heading", heading)) + for line in _wrap(body): + if len(current) >= lines_per_page: + flush() + current = [] + current.append(("body", line)) + flush() + if not pages: + pages = [[("body", "")]] + + content_streams: list[bytes] = [] + for page_lines in pages: + ops: list[str] = ["BT"] + y = PAGE_HEIGHT - MARGIN + for kind, line in page_lines: + size = HEADING_SIZE if kind == "heading" else BODY_SIZE + font = "/F2" if kind == "heading" else "/F1" + ops.append(f"{font} {size:.1f} Tf") + ops.append(f"1 0 0 1 {MARGIN:.2f} {y:.2f} Tm") + ops.append(f"({_escape(line)}) Tj") + y -= LEADING + ops.append("ET") + content_streams.append("\n".join(ops).encode("latin-1", errors="replace")) + + objects: list[bytes] = [] + n_content = len(content_streams) + # Object numbering: 1 catalog, 2 pages, 3 F1, 4 F2, then page pairs. + first_page_obj = 5 + page_ids = [first_page_obj + 2 * i for i in range(n_content)] + kids = " ".join(f"{pid} 0 R" for pid in page_ids) + objects.append(b"<< /Type /Catalog /Pages 2 0 R >>") + objects.append(f"<< /Type /Pages /Kids [{kids}] /Count {n_content} >>".encode()) + objects.append( + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>" + ) + objects.append( + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>" + ) + for index, stream in enumerate(content_streams): + page_id = page_ids[index] + content_id = page_id + 1 + objects.append( + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + f"{PAGE_WIDTH:.2f} {PAGE_HEIGHT:.2f}] /Resources << /Font " + f"<< /F1 3 0 R /F2 4 0 R >> >> /Contents {content_id} 0 R >>" + ).encode() + ) + objects.append( + b"<< /Length " + + str(len(stream)).encode() + + b" >>\nstream\n" + + stream + + b"\nendstream" + ) + + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for number, body in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{number} 0 obj\n".encode() + body + b"\nendobj\n" + xref_at = len(out) + out += f"xref\n0 {len(objects) + 1}\n".encode() + out += b"0000000000 65535 f \n" + for offset in offsets[1:]: + out += f"{offset:010d} 00000 n \n".encode() + out += ( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_at}\n%%EOF\n" + ).encode() + return bytes(out) + + +def problem_bundle_pdf(problem, samples) -> bytes: + """The offline study bundle for one problem: title, statements (EN then + RO when present), limits, provenance and every sample test.""" + title = ( + problem.title_i18n.get("en") or next(iter(problem.title_i18n.values()), "") + if isinstance(problem.title_i18n, dict) + else "" + ) + sections: list[tuple[str, str]] = [(title or f"Problem {problem.id}", "")] + for lang in ("en", "ro"): + statement = ( + problem.statement_i18n.get(lang, "") + if isinstance(problem.statement_i18n, dict) + else "" + ) + if statement: + sections.append((f"Statement ({lang.upper()})", statement)) + sections.append( + ( + "Limits", + f"Time: {problem.default_time_limit_ms} ms\n" + f"Memory: {problem.default_memory_limit_kb} KB", + ) + ) + if problem.source_contest or problem.source_year or problem.grade_group: + sections.append( + ( + "Provenance", + " ".join( + part + for part in ( + problem.source_contest, + str(problem.source_year or ""), + problem.grade_group, + ) + if part + ), + ) + ) + for index, test in enumerate(samples, start=1): + sections.append( + ( + f"Sample {index}", + f"Input:\n{test.input_data}\nExpected output:\n{test.expected_output_data}", + ) + ) + return build_pdf(sections) diff --git a/backend/problems/serializers.py b/backend/problems/serializers.py index be7c75eb..0bc516dd 100644 --- a/backend/problems/serializers.py +++ b/backend/problems/serializers.py @@ -6,6 +6,7 @@ normalize_localized_dict, ) from .models import ( + ImportSource, CurriculumNode, ProblemEditorial, Tag, @@ -85,6 +86,22 @@ class ProblemSerializer(serializers.ModelSerializer): # from the registry multipliers/floors + this problem's overrides. A # read-only projection — authors edit limit_overrides, not this. effective_limits = serializers.SerializerMethodField() + # Archive provenance (competitive roadmap RO-6/CNT-2): where the + # problem came from. ``import_source`` points at the permission-ledger + # row covering the import; approve-time gating enforces that a problem + # wired to a ledger row only publishes while that row is GRANTED. + import_source = serializers.PrimaryKeyRelatedField( + queryset=ImportSource.objects.all(), + required=False, + allow_null=True, + ) + import_source_name = serializers.SerializerMethodField() + + def get_import_source_name(self, obj) -> str | None: + # Dotted sources raise on null FKs; the method form renders the + # ledger row's name or None without traversal gymnastics. + return obj.import_source.name if obj.import_source_id else None + # Statement-language availability (i18n wave, owner rule (c)/(d)): # which languages the problem carries — clients render "available in # your language" badges and the EN-fallback affordance from this list @@ -168,6 +185,12 @@ class Meta: "last_modified_date", "submission_count", "solved_count", + # Archive provenance (RO-6 browse axis + CNT-2 ledger link). + "source_contest", + "source_year", + "grade_group", + "import_source", + "import_source_name", "tags", "tag_ids", # 'test_cases' # Add if you want to nest test cases in problem detail view diff --git a/backend/problems/test_pdf_bundle.py b/backend/problems/test_pdf_bundle.py new file mode 100644 index 00000000..6f65a7f6 --- /dev/null +++ b/backend/problems/test_pdf_bundle.py @@ -0,0 +1,135 @@ +"""Unit tests for the dependency-free PDF writer (problems/pdf_bundle.py).""" + +from django.test import TestCase + +from problems.models import Problem +from problems.models import TestCase as ProblemTestCase +from problems.pdf_bundle import ( + MAX_CHARS_PER_LINE, + _escape, + _wrap, + build_pdf, + problem_bundle_pdf, +) + + +class EscapeTests(TestCase): + def test_escapes_pdf_parens_and_backslash(self): + self.assertEqual(_escape("a(b)c"), "a\\(b\\)c") + self.assertEqual(_escape("back\\slash"), "back\\\\slash") + + def test_non_latin1_replaced(self): + self.assertEqual(_escape("șăÎ"), "??Î") + self.assertEqual(_escape("plain"), "plain") + + +class WrapTests(TestCase): + def test_short_lines_preserved(self): + self.assertEqual(_wrap("one\ntwo"), ["one", "two"]) + + def test_long_line_wraps_on_words(self): + wrapped = _wrap("word " * 40) + self.assertTrue(all(len(line) <= MAX_CHARS_PER_LINE for line in wrapped)) + self.assertGreater(len(wrapped), 1) + + def test_single_long_word_kept(self): + wrapped = _wrap("x" * 200) + self.assertEqual(wrapped, ["x" * 200]) + + def test_empty_text(self): + self.assertEqual(_wrap(""), [""]) + + +class BuildPdfTests(TestCase): + def test_valid_pdf_skeleton(self): + pdf = build_pdf([("Title", "Body line")]) + self.assertTrue(pdf.startswith(b"%PDF-1.4")) + self.assertIn(b"/Type /Catalog", pdf) + self.assertIn(b"/Type /Pages", pdf) + self.assertIn(b"/Count 1", pdf) + self.assertIn(b"/F2 14.0 Tf", pdf) # heading font + size + self.assertIn(b"(Body line) Tj", pdf) + self.assertTrue(pdf.rstrip().endswith(b"%%EOF")) + xref_at = int(pdf.rsplit(b"startxref", 1)[1].split()[0]) + self.assertEqual(pdf[xref_at : xref_at + 4], b"xref") + + def test_multiple_pages(self): + pdf = build_pdf([("Section", "\n".join(f"line {i}" for i in range(200)))]) + self.assertIn(b"/Count 4", pdf) + + def test_empty_sections_renders_blank_page(self): + pdf = build_pdf([]) + self.assertTrue(pdf.startswith(b"%PDF-1.4")) + self.assertIn(b"/Count 1", pdf) + + def test_escaped_content(self): + pdf = build_pdf([("T", "a(b)")]) + self.assertIn(b"\\(", pdf) + + +def _problem(**kw): + defaults = dict( + title_i18n={"en": "Sum", "ro": "Suma"}, + statement_i18n={"en": "Add numbers.", "ro": "Adunați numerele."}, + status=Problem.ProblemStatus.APPROVED, + ) + defaults.update(kw) + return Problem.objects.create(**defaults) + + +class ProblemBundlePdfTests(TestCase): + def test_full_bundle(self): + problem = _problem( + source_contest="OJI 2024", + source_year=2024, + grade_group="IX-X", + ) + ProblemTestCase.objects.create( + problem=problem, + input_data="1 2", + expected_output_data="3", + is_sample=True, + points=10, + ) + pdf = problem_bundle_pdf(problem, problem.test_cases.all()) + self.assertTrue(pdf.startswith(b"%PDF-1.4")) + # Headings are PDF-escaped (parens) inside the content stream. + escaped_en = "(Statement \\(EN\\)) Tj".encode() + escaped_ro = "(Statement \\(RO\\)) Tj".encode() + self.assertIn(escaped_en, pdf) + self.assertIn(escaped_ro, pdf) + self.assertIn(b"(Limits) Tj", pdf) + self.assertIn(b"(Provenance) Tj", pdf) + self.assertIn(b"(Sample 1) Tj", pdf) + self.assertIn(b"(OJI 2024 2024 IX-X) Tj", pdf) + + def test_no_provenance_section_when_plain(self): + problem = _problem() + pdf = problem_bundle_pdf(problem, []) + self.assertNotIn(b"(Provenance) Tj", pdf) + + def test_title_fallback_to_first_available(self): + problem = _problem(title_i18n={"ro": "Doar română"}) + pdf = problem_bundle_pdf(problem, []) + self.assertIn(b"Doar", pdf) + + +class PaginationBoundaryTests(TestCase): + """The section-heading page-break path (build_pdf lines 68-70).""" + + def test_new_section_flushes_full_page(self): + # One full page of body lines, then a second section whose heading + # lands past the page capacity -> the heading opens page 2. + first_body = "\n".join(f"l{i}" for i in range(50)) + pdf = build_pdf([("A", first_body), ("B", "tail")]) + self.assertIn(b"/Count 2", pdf) + self.assertIn(b"(B) Tj", pdf) + + +class MissingStatementLocaleTests(TestCase): + def test_empty_ro_statement_skips_section(self): + problem = _problem(statement_i18n={"en": "Only EN.", "ro": ""}) + pdf = problem_bundle_pdf(problem, []) + # Headings are paren-escaped inside the PDF content stream. + self.assertIn("(Statement \(EN\)) Tj".encode(), pdf) + self.assertNotIn("(Statement \(RO\)) Tj".encode(), pdf) diff --git a/backend/problems/test_tooling_closure.py b/backend/problems/test_tooling_closure.py new file mode 100644 index 00000000..3ef9092e --- /dev/null +++ b/backend/problems/test_tooling_closure.py @@ -0,0 +1,830 @@ +"""Wave C closure tests: JDG-11 remainder (validator runner, expected-output +generation, Kattis/Polygon package import) + RO-6 (archive browse axis, +ledger publication gate, offline PDF bundle).""" + +import base64 +import io +import zipfile +from unittest import mock + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from problems.models import ImportSource, Problem +from problems.models import TestCase as ProblemTestCase +from problems import tooling +from users.models import User + + +def _mk_problem(title, **kw): + problem = Problem.objects.create( + title_i18n={"en": title, "ro": title}, + statement_i18n={"en": "s", "ro": "s"}, + status=kw.pop("status", Problem.ProblemStatus.APPROVED), + **kw, + ) + ProblemTestCase.objects.create( + problem=problem, + input_data="1", + expected_output_data="2", + points=10, + is_sample=True, + ) + return problem + + +class ClosureBase(TestCase): + def setUp(self): + from django.core.cache import cache + + cache.clear() + self.admin = User.objects.create_user( + username="cl_admin", password="pw", role=User.Roles.ADMIN + ) + self.verifier = User.objects.create_user( + username="cl_verif", password="pw", role=User.Roles.PROBLEM_VERIFIER + ) + self.creator = User.objects.create_user( + username="cl_creator", password="pw", role=User.Roles.PROBLEM_CREATOR + ) + self.student = User.objects.create_user(username="cl_stud", password="pw") + self.client = APIClient() + + +# --------------------------------------------------------------------------- +# JDG-11: validator runner +# --------------------------------------------------------------------------- + + +class ValidatorRunTests(ClosureBase): + def payload(self): + return { + "generator_language": "python3", + "generator_code": "print(3)", + "validator_language": "python3", + "validator_code": "exit(0)", + "iterations": 3, + } + + def test_role_gate(self): + self.client.force_authenticate(self.student) + response = self.client.post( + "/api/v1/problems/tools/validate/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_unknown_language_rejected(self): + self.client.force_authenticate(self.creator) + bad = self.payload() + bad["validator_language"] = "cobol" + response = self.client.post( + "/api/v1/problems/tools/validate/", bad, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("cobol", str(response.data["validator_language"])) + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_all_valid(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + calls = iter( + [ + ("AC", 10, 1024, "3\n", ""), # generator + ("AC", 10, 1024, "", ""), # validator exit 0 + ] + * 10 + ) + run_mock.side_effect = lambda *a, **k: next(calls) + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/validate/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue(response.data["valid"]) + self.assertEqual(response.data["iterations"], 3) + self.assertIsNone(response.data["violation"]) + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_first_violation_surfaced(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + calls = iter( + [ + ("AC", 10, 1024, "42\n", ""), # generator + ("RE", 10, 1024, "", "n too big"), # validator rejects + ] + ) + run_mock.side_effect = lambda *a, **k: next(calls) + self.client.force_authenticate(self.verifier) + response = self.client.post( + "/api/v1/problems/tools/validate/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertFalse(response.data["valid"]) + self.assertEqual(response.data["violation"]["phase"], "validator") + self.assertEqual(response.data["violation"]["input"], "42\n") + self.assertEqual(response.data["violation"]["message"], "n too big") + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_generator_crash(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + run_mock.return_value = ("RE", 10, 1024, "", "boom") + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/validate/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["violation"]["phase"], "generator") + + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_compile_failure_returns_compiler_text(self, compile_mock): + compile_mock.return_value = (False, "SyntaxError: bad", None) + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/validate/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + # The generator compiles first, so its diagnostics come back first. + self.assertIn("SyntaxError", str(response.data["generator_code"])) + + +# --------------------------------------------------------------------------- +# JDG-11: expected-output generation +# --------------------------------------------------------------------------- + + +class GenerateOutputsTests(ClosureBase): + def payload(self): + return { + "reference_language": "python3", + "reference_code": "print(int(input())*2)", + "inputs": ["1", "2"], + } + + def test_role_gate(self): + self.client.force_authenticate(self.student) + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_unknown_reference_language(self): + self.client.force_authenticate(self.creator) + bad = self.payload() + bad["reference_language"] = "cobol" + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", bad, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_missing_inputs_and_generator(self): + self.client.force_authenticate(self.creator) + bad = self.payload() + del bad["inputs"] + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", bad, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_explicit_inputs_happy_path(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + calls = iter( + [ + ("AC", 10, 1024, "2\n", ""), + ("AC", 10, 1024, "4\n", ""), + ] + ) + run_mock.side_effect = lambda *a, **k: next(calls) + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["count"], 2) + self.assertEqual(response.data["results"][0]["output"], "2\n") + self.assertEqual(response.data["results"][1]["input"], "2") + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_non_ac_reference_reported_per_input(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + run_mock.return_value = ("TLE", 10, 1024, "", "slow") + self.client.force_authenticate(self.verifier) + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["results"][0]["verdict"], "TLE") + self.assertEqual(response.data["results"][0]["message"], "slow") + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_generator_batch_mode(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + calls = iter( + [ + ("AC", 10, 1024, "9\n", ""), # generator + ("AC", 10, 1024, "18\n", ""), # reference + ] + ) + run_mock.side_effect = lambda *a, **k: next(calls) + self.client.force_authenticate(self.creator) + payload = { + "reference_language": "python3", + "reference_code": "print(int(input())*2)", + "generator_language": "python3", + "generator_code": "print(9)", + "count": 1, + } + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", payload, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["count"], 1) + self.assertEqual(response.data["results"][0]["output"], "18\n") + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_generator_bad_verdict_400(self, compile_mock, run_mock): + compile_mock.return_value = (True, "", "/tmp/fake.exe") + run_mock.return_value = ("RE", 10, 1024, "", "boom") + self.client.force_authenticate(self.creator) + payload = { + "reference_language": "python3", + "reference_code": "print(1)", + "generator_language": "python3", + "generator_code": "boom", + "count": 2, + } + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", payload, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_reference_compile_failure(self, compile_mock): + compile_mock.return_value = (False, "bad ref", None) + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("bad ref", str(response.data["reference_code"])) + + @mock.patch("problems.tooling.run_code_in_sandbox") + @mock.patch("problems.tooling.compile_code_in_sandbox") + def test_generator_compile_failure(self, compile_mock, run_mock): + compile_mock.side_effect = [ + (True, "", "/tmp/ref.exe"), + (False, "bad gen", None), + ] + self.client.force_authenticate(self.creator) + payload = { + "reference_language": "python3", + "reference_code": "print(1)", + "generator_language": "python3", + "generator_code": "boom", + "count": 1, + } + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", payload, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("bad gen", str(response.data["generator_code"])) + + +# --------------------------------------------------------------------------- +# JDG-11 / RO-6: Kattis/Polygon package import +# --------------------------------------------------------------------------- + + +def _package_bytes(): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr( + "problem.yaml", + "name: sum-two\nauthor: OJI\nlimits:\n time_seconds: 2\n" + " memory_limit_bytes: 536870912\n", + ) + package.writestr( + "statements/en/problem.tex", + "\\title{Sum Two}\nRead two integers and print their sum.\n", + ) + package.writestr("data/sample/1.in", "1 2\n") + package.writestr("data/sample/1.ans", "3\n") + package.writestr("data/secret/2.in", "3 4\n") + package.writestr("data/secret/2.out", "7\n") + # An .in without an answer is skipped. + package.writestr("data/secret/3.in", "9 9\n") + # Legacy Polygon-style flat tests also pair. + package.writestr("tests/legacy.in", "5 5\n") + package.writestr("tests/legacy.a", "10\n") + # Statements never pair as tests. + package.writestr("statements/ro/problem.tex", "Aduna doi intregi.\n") + return base64.b64encode(buffer.getvalue()).decode() + + +class ImportPackageTests(ClosureBase): + def setUp(self): + super().setUp() + self.granted = ImportSource.objects.create( + name="OJI archive", + status=ImportSource.Status.GRANTED, + contact="isj@example.ro", + terms="Educational re-use with attribution.", + evidence_url="https://example.ro/license", + ) + self.pending = ImportSource.objects.create( + name="Pending archive", status=ImportSource.Status.PENDING + ) + + def payload(self, **overrides): + base = {"package_base64": _package_bytes(), "import_source_id": self.granted.id} + base.update(overrides) + for key in [k for k, v in overrides.items() if v is None]: + del base[key] + return base + + def test_role_gate_rejects_creator(self): + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/tools/import-package/", self.payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_import_with_granted_ledger(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload( + source_contest="OJI 2024", + source_year=2024, + grade_group="IX-X", + ), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + problem = Problem.objects.get(pk=response.data["problem_id"]) + self.assertEqual(problem.status, Problem.ProblemStatus.DRAFT) + self.assertEqual(problem.title_i18n["en"], "Sum Two") + self.assertIn("their sum", problem.statement_i18n["en"]) + self.assertEqual(problem.default_time_limit_ms, 2000) + self.assertEqual(problem.default_memory_limit_kb, 524288) + self.assertEqual(problem.source_contest, "OJI 2024") + self.assertEqual(problem.source_year, 2024) + self.assertEqual(problem.import_source_id, self.granted.id) + # 1 sample + 1 secret + 1 legacy Polygon pair = 3 tests; the + # answer-less 3.in is skipped; statements never pair. + self.assertEqual(response.data["tests_imported"], 3) + self.assertEqual(response.data["samples"], 1) + self.assertTrue( + ProblemTestCase.objects.filter( + problem=problem, is_sample=True, input_data="1 2\n" + ).exists() + ) + self.assertTrue( + ProblemTestCase.objects.filter( + problem=problem, is_sample=False, input_data="5 5\n" + ).exists() + ) + + def test_import_with_declared_own_content(self): + self.client.force_authenticate(self.verifier) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(import_source_id=None, declare_own_content=True), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + self.assertEqual(response.data["import_source"], "declared own content") + self.assertIsNone( + Problem.objects.get(pk=response.data["problem_id"]).import_source_id + ) + + def test_pending_ledger_blocked(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(import_source_id=self.pending.id), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("PENDING", str(response.data["import_source_id"])) + + def test_unknown_ledger_blocked(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(import_source_id=99999), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_neither_ledger_nor_declaration_blocked(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(import_source_id=None), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("declare_own_content", str(response.data["detail"])) + + def test_invalid_base64(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64="!!!not-base64!!!"), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_not_a_zip(self): + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64=base64.b64encode(b"plain text").decode()), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_no_test_pairs(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr("problem.yaml", "name: empty\n") + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64=base64.b64encode(buffer.getvalue()).decode()), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_package_too_large(self): + self.client.force_authenticate(self.admin) + with mock.patch.object(tooling, "MAX_PACKAGE_BYTES", 10): + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_too_many_entries(self): + self.client.force_authenticate(self.admin) + with mock.patch.object(tooling, "MAX_PACKAGE_ENTRIES", 2): + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_yaml_without_name_uses_fallback_title(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr("data/sample/a.in", "1\n") + package.writestr("data/sample/a.out", "2\n") + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64=base64.b64encode(buffer.getvalue()).decode()), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn("Imported problem", response.data["title"]) + + def test_import_caps_test_count(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + for index in range(5): + package.writestr(f"data/secret/{index}.in", f"{index}\n") + package.writestr(f"data/secret/{index}.out", f"{index}\n") + self.client.force_authenticate(self.admin) + with mock.patch.object(tooling, "MAX_IMPORT_TESTS", 2): + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload( + package_base64=base64.b64encode(buffer.getvalue()).decode() + ), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["tests_imported"], 2) + + def test_traversal_names_never_pair(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr("data/sample/ok.in", "1\n") + package.writestr("data/sample/ok.out", "2\n") + package.writestr("../evil.in", "3\n") + package.writestr("../evil.out", "4\n") + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64=base64.b64encode(buffer.getvalue()).decode()), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["tests_imported"], 1) + + def test_yaml_limits_garbage_falls_back(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr( + "problem.yaml", + "name: x\nlimits:\n time_seconds: banana\n" + " memory_limit_bytes: nope\n", + ) + package.writestr("data/sample/a.in", "1\n") + package.writestr("data/sample/a.out", "2\n") + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + self.payload(package_base64=base64.b64encode(buffer.getvalue()).decode()), + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + problem = Problem.objects.get(pk=response.data["problem_id"]) + self.assertEqual(problem.default_time_limit_ms, 1000) + self.assertEqual(problem.default_memory_limit_kb, 262144) + + +class FlatYamlParserTests(TestCase): + """The tiny problem.yaml subset parser (no PyYAML dependency).""" + + def test_flat_and_nested_keys(self): + parsed = tooling.parse_flat_yaml( + '# comment\nname: sum-two\nauthor: "OJI"\nlimits:\n' + " time_seconds: 2\n memory_limit_bytes: 536870912\n" + "\tjunk: skipped\nno_colon_line\n" + ) + self.assertEqual(parsed["name"], "sum-two") + self.assertEqual(parsed["author"], "OJI") + self.assertEqual( + parsed["limits"], + {"time_seconds": "2", "memory_limit_bytes": "536870912"}, + ) + + def test_empty_and_comments_only(self): + self.assertEqual(tooling.parse_flat_yaml(""), {}) + self.assertEqual(tooling.parse_flat_yaml("# nothing\n\n"), {}) + + +# --------------------------------------------------------------------------- +# RO-6: archive browse axis + ledger publication gate +# --------------------------------------------------------------------------- + + +class ArchiveAxisTests(ClosureBase): + def setUp(self): + super().setUp() + self.granted = ImportSource.objects.create( + name="Granted", status=ImportSource.Status.GRANTED + ) + self.revoked = ImportSource.objects.create( + name="Revoked", status=ImportSource.Status.REJECTED + ) + self.archive_problem = _mk_problem( + "OJI 2024 problem", + source_contest="OJI 2024", + source_year=2024, + grade_group="IX-X", + ) + self.plain_problem = _mk_problem("Plain problem") + + def list_ids(self, query=""): + response = self.client.get(f"/api/v1/problems/problems/{query}") + return {row["id"] for row in response.data["results"]} + + def test_archive_filter(self): + ids = self.list_ids("?archive=1") + self.assertIn(self.archive_problem.id, ids) + self.assertNotIn(self.plain_problem.id, ids) + # Equivalent truthy spellings. + self.assertEqual(ids, self.list_ids("?archive=true")) + self.assertEqual(ids, self.list_ids("?archive=yes")) + + def test_source_year_filter(self): + ids = self.list_ids("?source_year=2024") + self.assertEqual(ids, {self.archive_problem.id}) + self.assertFalse(self.list_ids("?source_year=1999")) + + def test_grade_group_filter(self): + ids = self.list_ids("?grade_group=IX-X") + self.assertEqual(ids, {self.archive_problem.id}) + + def test_serializer_roundtrip_provenance(self): + self.client.force_authenticate(self.creator) + response = self.client.post( + "/api/v1/problems/problems/", + { + "title_i18n": {"en": "Imported", "ro": "Importat"}, + "statement_i18n": {"en": "s", "ro": "s"}, + "difficulty": "EASY", + "source_contest": "ONI 2023", + "source_year": 2023, + "grade_group": "XI-XII", + "import_source": self.granted.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + body = response.data + self.assertEqual(body["source_contest"], "ONI 2023") + self.assertEqual(body["import_source"], self.granted.id) + self.assertEqual(body["import_source_name"], "Granted") + detail = self.client.get(f"/api/v1/problems/problems/{body['id']}/").data + self.assertEqual(detail["import_source_name"], "Granted") + + def test_serializer_null_import_source_renders(self): + response = self.client.get( + f"/api/v1/problems/problems/{self.plain_problem.id}/" + ) + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.data["import_source_name"]) + self.assertIsNone(response.data["import_source"]) + + def test_submit_blocked_without_granted_ledger(self): + problem = _mk_problem( + "Blocked import", + status=Problem.ProblemStatus.DRAFT, + author=self.creator, + import_source=self.revoked, + ) + self.client.force_authenticate(self.creator) + response = self.client.post( + f"/api/v1/problems/problems/{problem.id}/submit-for-approval/" + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn("Revoked", str(response.data["status"])) + problem.refresh_from_db() + self.assertEqual(problem.status, Problem.ProblemStatus.DRAFT) + + def test_submit_allowed_with_granted_ledger(self): + problem = _mk_problem( + "Allowed import", + status=Problem.ProblemStatus.DRAFT, + author=self.creator, + import_source=self.granted, + ) + self.client.force_authenticate(self.creator) + response = self.client.post( + f"/api/v1/problems/problems/{problem.id}/submit-for-approval/" + ) + self.assertEqual(response.status_code, 200, response.data) + problem.refresh_from_db() + self.assertEqual(problem.status, Problem.ProblemStatus.PENDING_APPROVAL) + + def test_approve_blocked_when_ledger_not_granted(self): + problem = _mk_problem( + "Approve blocked", + status=Problem.ProblemStatus.PENDING_APPROVAL, + author=self.creator, + import_source=self.revoked, + ) + self.client.force_authenticate(self.verifier) + response = self.client.post(f"/api/v1/problems/problems/{problem.id}/approve/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + problem.refresh_from_db() + self.assertEqual(problem.status, Problem.ProblemStatus.PENDING_APPROVAL) + + def test_public_api_exposes_provenance(self): + response = self.client.get( + f"/api/v1/public/problems/{self.archive_problem.id}/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["source_contest"], "OJI 2024") + self.assertEqual(response.data["source_year"], 2024) + listed = self.client.get("/api/v1/public/problems/") + row = next( + r for r in listed.data["results"] if r["id"] == self.archive_problem.id + ) + self.assertEqual(row["grade_group"], "IX-X") + + +# --------------------------------------------------------------------------- +# RO-6: offline study bundle (PDF) +# --------------------------------------------------------------------------- + + +class OfflineBundleTests(ClosureBase): + def test_pdf_download(self): + response = self.client.get( + f"/api/v1/problems/{_mk_problem('Bundled').id}/offline-bundle/" + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertIn("attachment", response["Content-Disposition"]) + body = ( + b"".join(response.streaming_content) + if hasattr(response, "streaming_content") + else response.content + ) + self.assertTrue(body.startswith(b"%PDF-1.4")) + self.assertIn(b"%%EOF", body) + + def test_unknown_problem_404(self): + response = self.client.get("/api/v1/problems/999999/offline-bundle/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_unapproved_problem_404(self): + draft = _mk_problem("Draft", status=Problem.ProblemStatus.DRAFT) + response = self.client.get(f"/api/v1/problems/{draft.id}/offline-bundle/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class ToolingCoverageClosureTests(ClosureBase): + """Branch-closure cases for the validator/generator/import lanes.""" + + def test_safe_member_name_direct(self): + self.assertFalse(tooling._safe_member_name("")) + self.assertFalse(tooling._safe_member_name("/abs/path")) + self.assertFalse(tooling._safe_member_name("rel/../../etc")) + self.assertTrue(tooling._safe_member_name("data/sample/1.in")) + + def test_generate_outputs_unknown_generator_language(self): + self.client.force_authenticate(self.creator) + payload = { + "reference_language": "python3", + "reference_code": "print(1)", + "generator_language": "cobol", + "generator_code": "MOVE 1 TO X", + "count": 1, + } + response = self.client.post( + "/api/v1/problems/tools/generate-outputs/", payload, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("cobol", str(response.data["generator_language"])) + + def test_yaml_orphan_indented_lines(self): + parsed = tooling.parse_flat_yaml( + "name: x\n orphan_no_colon\nlimits:\n ok: 1\n" + ) + self.assertEqual(parsed["name"], "x") + self.assertEqual(parsed["limits"], {"ok": "1"}) + # An indented line before any nested block opens nothing. + self.assertEqual(tooling.parse_flat_yaml("top: v\n stray: 1"), {"top": "v"}) + + def test_statement_in_unknown_language_and_stray_markdown(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr("statements/fr/problem.tex", "Bonjour\n") + package.writestr("docs/notes.md", "not a statement\n") + # Statement-dir .in files never pair as tests; no \title either. + package.writestr("statements/meta.in", "1\n") + package.writestr("statements/meta.out", "2\n") + package.writestr("data/sample/a.in", "1\n") + package.writestr("data/sample/a.out", "2\n") + self.client.force_authenticate(self.admin) + source = ImportSource.objects.create( + name="Own", status=ImportSource.Status.GRANTED + ) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + { + "package_base64": base64.b64encode(buffer.getvalue()).decode(), + "import_source_id": source.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + # fr text ignored, no \title -> fallback title, statements/x.in skipped. + self.assertNotEqual(response.data["title"], "Bonjour") + self.assertEqual(response.data["tests_imported"], 1) + problem = Problem.objects.get(pk=response.data["problem_id"]) + self.assertIn("statement pending", problem.statement_i18n["en"]) + + +class ToolingFinalBranchTests(ClosureBase): + """The last two flat-yaml/statement branches.""" + + def test_colonless_line_inside_nested_block(self): + parsed = tooling.parse_flat_yaml("limits:\n no colon here\n ok: 1\n") + self.assertEqual(parsed["limits"], {"ok": "1"}) + + def test_english_statement_without_title(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as package: + package.writestr("statements/en/problem.tex", "No title command.\n") + package.writestr("data/sample/a.in", "1\n") + package.writestr("data/sample/a.out", "2\n") + self.client.force_authenticate(self.admin) + response = self.client.post( + "/api/v1/problems/tools/import-package/", + { + "package_base64": base64.b64encode(buffer.getvalue()).decode(), + "declare_own_content": True, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data) + # No \title -> falls back to the generic imported-problem title. + self.assertIn("Imported problem", response.data["title"]) + problem = Problem.objects.get(pk=response.data["problem_id"]) + self.assertEqual(problem.statement_i18n["en"], "No title command.\n") diff --git a/backend/problems/tooling.py b/backend/problems/tooling.py index 55d37fbc..0992b71e 100644 --- a/backend/problems/tooling.py +++ b/backend/problems/tooling.py @@ -1,10 +1,17 @@ """Authoring & integrity tooling (competitive roadmap Wave C). -JDG-11 — the stress-test helper: run a generator + two solutions N times -inside the same sandboxed mechanics the scratch pad uses, report the first -input where the outputs diverge. This is the "find the failing test" -loop every competitive programmer runs by hand; the platform runs it -inside its own throttled, audited lane. +JDG-11 — the problem-preparation toolchain, all through the same +sandboxed mechanics the scratch pad uses (throttled, audited lane): + * the stress-test helper: generator + two solutions N times, report the + first input where the outputs diverge — the "find the failing test" + loop every competitive programmer runs by hand; + * the validator runner: a testlib-style validator (exit 0 = the input + satisfies the constraints) checked against N generated inputs; + * expected-output generation: run a trusted reference over author + supplied (or generated) inputs to mint .out files for real test cases; + * the Kattis/Polygon problem-package importer: turn an archive package + zip into a DRAFT problem + test cases wired to the CNT-2 permission + ledger (rights-checked by construction). JDG-13 (v1 slice) — the teacher/admin "download all solutions for this homework" export: per-assignment CSV of every counted submission with @@ -12,11 +19,16 @@ stays deferred until contest volume justifies it — round-2 D.8). """ +import base64 +import binascii import csv import io import pathlib +import re +import zipfile from rest_framework import permissions, serializers, status +from django.http import HttpResponse from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView @@ -30,7 +42,8 @@ from submissions.tasks import _judge_workspace from users.models import User -from .models import Problem, TestCase +from .models import ImportSource, Problem, TestCase +from .pdf_bundle import problem_bundle_pdf #: Safety ceiling for stress iterations (the loop is user-paced, sandboxed #: and throttled; the cap keeps one request from monopolizing the lane). @@ -336,3 +349,619 @@ def get(self, request, problem_id): "content": "\n".join(sections), } ) + + +class OfflineBundleView(APIView): + """GET /api/v1/problems/{problem_id}/offline-bundle/ (RO-6). + + The archive's "PDF bundle download for offline study": statement (EN + + RO), limits, provenance and every sample test in one printable PDF, + generated dependency-free (problems/pdf_bundle.py). Public exactly like + the statement itself -- hidden test data never enters the file. + """ + + permission_classes = [permissions.AllowAny] + + def get(self, request, problem_id): + problem = Problem.objects.filter( + pk=problem_id, status=Problem.ProblemStatus.APPROVED + ).first() + if problem is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + samples = TestCase.objects.filter(problem=problem, is_sample=True).order_by( + "order" + ) + pdf_bytes = problem_bundle_pdf(problem, samples) + response = HttpResponse(pdf_bytes, content_type="application/pdf") + response["Content-Disposition"] = ( + 'attachment; filename="problem-%d-bundle.pdf"' % problem.id + ) + return response + + +# --------------------------------------------------------------------------- +# JDG-11 remainder: validator runner + expected-output generation. +# --------------------------------------------------------------------------- + + +def _authoring_gate(user) -> bool: + """The stress-lane role gate, shared by every tools/ sandbox runner.""" + return bool( + user.is_superuser + or user.is_staff + or getattr(user, "role", "") + in ( + User.Roles.ADMIN, + User.Roles.PROBLEM_VERIFIER, + User.Roles.PROBLEM_CREATOR, + User.Roles.TEACHER, + ) + ) + + +def _run_full(executable_path, language: str, stdin_data: str, workspace_dir): + """Run one compiled artifact returning (verdict, stdout, stderr).""" + verdict, _time, _mem, output, error = run_code_in_sandbox( + executable_path, + language, + stdin_data, + STRESS_TIME_LIMIT_MS, + STRESS_MEMORY_LIMIT_KB, + workspace_dir, + None, + ) + return verdict, output or "", error or "" + + +def _compile_or_400(code, language, workspace_dir, key, request): + """Compile one program for a tools run. + + Returns (ok, executable, compiler_output); on failure the audit row is + persisted and the caller answers 400 with the compiler text. + """ + ok, compiler_output, executable = compile_code_in_sandbox( + code, language, workspace_dir, None + ) + if not ok: + StressRun.objects.create( + user=request.user, + iterations=0, + found=False, + detail={f"{key}_code": compiler_output[:2000]}, + ) + return ok, executable, compiler_output + + +class ValidateRequestSerializer(serializers.Serializer): + """Validator + generator pairing (the testlib loop, platform-hosted).""" + + generator_language = serializers.CharField(max_length=50) + generator_code = serializers.CharField() + validator_language = serializers.CharField(max_length=50) + validator_code = serializers.CharField() + iterations = serializers.IntegerField(min_value=1, default=10) + + +class ValidatorRunView(APIView): + """POST /api/v1/problems/tools/validate/ (JDG-11). + + A validator is a program that reads one generated input on stdin and + exits 0 when the input satisfies the problem constraints (the testlib + ``validator`` contract — any non-zero exit, crash or timeout marks the + input invalid). The loop runs generator -> validator up to ``iterations`` + times and returns the FIRST violating input with the validator's + message, so the author fixes the generator before the input ever + becomes a test case. Same role gate + throttle lane as the stress + runner; every run persists a StressRun audit row. + """ + + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "stress" + + def post(self, request): + if not _authoring_gate(request.user): + return Response( + {"detail": "Creators, teachers and verifiers only."}, + status=status.HTTP_403_FORBIDDEN, + ) + payload = ValidateRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = payload.validated_data + iterations = min(data["iterations"], MAX_STRESS_ITERATIONS) + for lang_key in ("generator_language", "validator_language"): + if get_language(data[lang_key]) is None: + return Response( + {lang_key: f"Unsupported language: {data[lang_key]}."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + violation = None + runs = 0 + with _judge_workspace("validate_") as workspace: + workspace_dir = pathlib.Path(workspace) + gen_exec = val_exec = None + gen_lang = val_lang = "" + for key, lang_key, code_key in ( + ("generator", "generator_language", "generator_code"), + ("validator", "validator_language", "validator_code"), + ): + ok, executable, compiler_output = _compile_or_400( + data[code_key], data[lang_key], workspace_dir, key, request + ) + if not ok: + return Response( + {f"{key}_code": compiler_output[:2000]}, + status=status.HTTP_400_BAD_REQUEST, + ) + if key == "generator": + gen_exec, gen_lang = executable, data[lang_key] + else: + val_exec, val_lang = executable, data[lang_key] + + for _index in range(iterations): + gen_verdict, gen_out, _gen_err = _run_full( + gen_exec, gen_lang, "", workspace_dir + ) + runs += 1 + if gen_verdict != "AC": + violation = {"phase": "generator", "verdict": gen_verdict} + break + val_verdict, _val_out, val_err = _run_full( + val_exec, val_lang, gen_out, workspace_dir + ) + if val_verdict != "AC": + # A validator must exit 0 on valid input (the testlib + # contract). A non-zero DELIBERATE exit (input rejected) + # and a crash/timeout both surface here with the input + + # the validator's message — either way the author fixes + # the generator before this input becomes a test. + violation = { + "phase": "validator", + "input": gen_out[:4000], + "verdict": val_verdict, + "message": val_err[:1000], + } + break + + record = StressRun.objects.create( + user=request.user, + iterations=runs, + found=violation is not None, + detail={"violation": violation} if violation else {}, + ) + return Response( + { + "id": record.id, + "iterations": record.iterations, + "valid": violation is None, + "violation": violation, + } + ) + + +class GenerateOutputsRequestSerializer(serializers.Serializer): + """Expected-output generation from a reference solution (JDG-11).""" + + reference_language = serializers.CharField(max_length=50) + reference_code = serializers.CharField() + # Either explicit inputs (list of stdin blobs)… + inputs = serializers.ListField( + child=serializers.CharField(allow_blank=True), required=False, max_length=50 + ) + # …or a generator run ``count`` times. + generator_language = serializers.CharField(max_length=50, required=False) + generator_code = serializers.CharField(required=False) + count = serializers.IntegerField(min_value=1, max_value=50, required=False) + + +class GenerateOutputsView(APIView): + """POST /api/v1/problems/tools/generate-outputs/ (JDG-11). + + Runs a trusted reference solution over author-supplied inputs (or a + generated batch) and returns input/output pairs ready to be pasted as + test cases — the cheapest correct way to mint the expected outputs of + a heavy-compute test (the roadmap's "expected-output generation from a + reference solution"). Same gate/throttle/audit lane as the stress + runner; non-AC reference verdicts are reported per input, never + silently turned into outputs. + """ + + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "stress" + + def post(self, request): + if not _authoring_gate(request.user): + return Response( + {"detail": "Creators, teachers and verifiers only."}, + status=status.HTTP_403_FORBIDDEN, + ) + payload = GenerateOutputsRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = payload.validated_data + if get_language(data["reference_language"]) is None: + return Response( + {"reference_language": "Unsupported language."}, + status=status.HTTP_400_BAD_REQUEST, + ) + inputs: list[str] = list(data.get("inputs") or []) + if not inputs: + gen_lang = data.get("generator_language") + gen_code = data.get("generator_code") + count = data.get("count") + if not (gen_lang and gen_code and count): + return Response( + {"detail": "Provide `inputs` or a generator + `count`."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if get_language(gen_lang) is None: + return Response( + {"generator_language": f"Unsupported language: {gen_lang}."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + results: list[dict] = [] + with _judge_workspace("genout_") as workspace: + workspace_dir = pathlib.Path(workspace) + ok, ref_exec, ref_log = _compile_or_400( + data["reference_code"], + data["reference_language"], + workspace_dir, + "reference", + request, + ) + if not ok: + return Response( + {"reference_code": ref_log[:2000]}, + status=status.HTTP_400_BAD_REQUEST, + ) + gen_exec = None + gen_lang = None + if not inputs: + ok, gen_exec, gen_log = _compile_or_400( + data["generator_code"], + data["generator_language"], + workspace_dir, + "generator", + request, + ) + if not ok: + return Response( + {"generator_code": gen_log[:2000]}, + status=status.HTTP_400_BAD_REQUEST, + ) + gen_lang = data["generator_language"] + for _ in range(data["count"]): + gen_verdict, gen_out, _err = _run_full( + gen_exec, gen_lang, "", workspace_dir + ) + if gen_verdict != "AC": + return Response( + {"detail": f"generator verdict {gen_verdict}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inputs.append(gen_out) + + for stdin_data in inputs: + verdict, out, err = _run_full( + ref_exec, data["reference_language"], stdin_data, workspace_dir + ) + entry = {"input": stdin_data[:20000], "output": out[:20000]} + if verdict != "AC": + entry["verdict"] = verdict + entry["message"] = err[:1000] + results.append(entry) + + record = StressRun.objects.create( + user=request.user, + iterations=len(results), + found=False, + detail={"generated_outputs": len(results)}, + ) + return Response( + { + "id": record.id, + "count": len(results), + "results": results, + } + ) + + +# --------------------------------------------------------------------------- +# JDG-11 / RO-6: Kattis/Polygon problem-package import (admin lane). +# --------------------------------------------------------------------------- + +#: Import ceilings — a package is a zip of text files; the caps keep one +#: request from becoming a storage/DoS vector. +MAX_PACKAGE_BYTES = 15 * 1024 * 1024 +MAX_PACKAGE_ENTRIES = 400 +MAX_IMPORT_TESTS = 200 + +_ANSWER_SUFFIXES = (".out", ".ans", ".a", ".txt") + + +def _safe_member_name(name: str) -> bool: + """Reject absolute paths and traversal (zip-slip discipline).""" + if not name or name.startswith("/") or name.startswith("\\"): + return False + parts = re.split(r"[/\\]", name) + return not any(part in ("..", "") for part in parts if part) and "\\" not in name + + +def parse_flat_yaml(text: str) -> dict: + """Minimal flat/2-level YAML subset parser for Kattis problem.yaml. + + Handles the subset the format actually uses — top-level ``key: value`` + pairs and one nesting level (``limits:`` / ``input_formats:`` blocks). + Values are kept as strings; callers coerce. Unknown structure is + ignored rather than raising (importers tolerate foreign keys). + """ + root: dict = {} + current: dict | None = None + for raw_line in text.splitlines(): + if not raw_line.strip() or raw_line.lstrip().startswith("#"): + continue + if raw_line.startswith("\t"): + continue # tabs are invalid YAML; skip defensively + indent = len(raw_line) - len(raw_line.lstrip(" ")) + line = raw_line.strip() + if indent == 0: + current = None + if ":" not in line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if value == "": + root[key] = {} + current = root[key] + else: + root[key] = value.strip("\"'") + elif current is not None and indent > 0: + # ``current`` is only ever None or the dict opened by an empty + # top-level value, so no isinstance dance is needed here. + if ":" not in line: + continue + key, _, value = line.partition(":") + current[key.strip()] = value.strip().strip("\"'") + return root + + +def _statement_from_zip(package: zipfile.ZipFile) -> tuple[str, str, str]: + """Best-effort statement: (en_text, ro_text, title). + + Kattis layout: ``statements//problem.tex`` (or ``.md``). Polygon + layout: ``statements/.html/en-US/problem.html`` (ignored — HTML is not + imported) and occasionally ``statements/english/problem.tex``. LaTeX + arrives verbatim in the DRAFT statement; the approval workflow polishes. + """ + en, ro, title = "", "", "" + for name in package.namelist(): + low = name.lower() + if not ( + low.endswith(".tex") or low.endswith(".md") or low.endswith(".markdown") + ): + continue + parts = re.split(r"[/\\]", name) + if len(parts) < 2 or parts[0].lower() != "statements": + continue + body = package.read(name).decode("utf-8", errors="replace") + lang = parts[1].lower() + if lang in ("en", "en-us", "english") and not en: + en = body + elif lang in ("ro", "romanian", "ro-ro") and not ro: + ro = body + if en: + match = re.search(r"\\title\{([^}]*)\}", en) + if match: + title = match.group(1).strip() + return en, ro, title + + +def _tests_from_zip(package: zipfile.ZipFile) -> list[tuple[str, str, str, bool]]: + """Collect test pairs: (name, input, answer, is_sample). + + Kattis: ``data/sample/x.in`` + ``data/sample/x.ans|.out``; secret under + ``data/secret/...``. Polygon exports (Kattis-compatible mode) use the + same tree; the legacy Polygon ``tests/`` flat layout is accepted too. + A test pairs an ``.in`` with its sibling answer file (same directory, + same stem, extension ``.out``/``.ans``/``.a``); an ``.in`` without an + answer is skipped, and anything under ``statements/`` never pairs. + Grouped sub-directories (``data/secret/01-...``) keep their relative + order by sorted walk. + """ + pairs: list[tuple[str, str, str, bool]] = [] + names = sorted(package.namelist()) + for name in names: + if not _safe_member_name(name) or name.endswith("/"): + continue + if not name.lower().endswith(".in"): + continue + parts = re.split(r"[/\\]", name) + if parts[0].lower() == "statements": + continue + stem = name[:-3] + answer = None + for ext in _ANSWER_SUFFIXES: + candidate = stem + ext + if candidate in names: + answer = package.read(candidate).decode("utf-8", errors="replace") + break + if answer is None: + continue + input_text = package.read(name).decode("utf-8", errors="replace") + is_sample = any(part.lower() == "sample" for part in parts) + pairs.append((stem, input_text, answer, is_sample)) + return pairs + + +class ImportPackageRequestSerializer(serializers.Serializer): + package_base64 = serializers.CharField() + # CNT-2: every archive import rides a GRANTED ledger row — or the + # importer asserts own/licensed content explicitly. + import_source_id = serializers.IntegerField(required=False) + declare_own_content = serializers.BooleanField(default=False) + source_contest = serializers.CharField(required=False, allow_blank=True) + source_year = serializers.IntegerField( + required=False, min_value=1900, max_value=2100 + ) + grade_group = serializers.CharField(required=False, allow_blank=True) + + +class ImportPackageView(APIView): + """POST /api/v1/problems/tools/import-package/ (JDG-11 + RO-6). + + Imports a Kattis/Polygon problem package (zip) as a DRAFT problem with + test cases, so archive materials enter the same approval + rights + pipeline as hand-authored problems. ADMIN/verifier only (the roadmap + scopes package import to admins); the CNT-2 ledger is consulted by + construction: a ``GRANTED`` ImportSource row must cover the import, + or the caller must explicitly declare own/licensed content. + """ + + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + user = request.user + if not ( + user.is_superuser + or user.is_staff + or getattr(user, "role", "") + in (User.Roles.ADMIN, User.Roles.PROBLEM_VERIFIER) + ): + return Response( + {"detail": "Administrators and verifiers only."}, + status=status.HTTP_403_FORBIDDEN, + ) + payload = ImportPackageRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = payload.validated_data + + source = None + if data.get("import_source_id") is not None: + source = ImportSource.objects.filter(pk=data["import_source_id"]).first() + if source is None: + return Response( + {"import_source_id": "Unknown import source."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if source.status != ImportSource.Status.GRANTED: + return Response( + { + "import_source_id": ( + f"Import source '{source.name}' is {source.status} — " + "only GRANTED sources may publish imports." + ) + }, + status=status.HTTP_403_FORBIDDEN, + ) + elif not data.get("declare_own_content"): + return Response( + { + "detail": ( + "Provide a GRANTED import_source_id or set " + "declare_own_content=true for your own/licensed material." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + raw = base64.b64decode(data["package_base64"], validate=True) + except (binascii.Error, ValueError): + return Response( + {"package_base64": "Invalid base64 payload."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if len(raw) > MAX_PACKAGE_BYTES: + return Response( + {"package_base64": "Package exceeds 15 MB."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + package = zipfile.ZipFile(io.BytesIO(raw)) + except zipfile.BadZipFile: + return Response( + {"package_base64": "Not a zip archive."}, + status=status.HTTP_400_BAD_REQUEST, + ) + names = package.namelist() + if len(names) > MAX_PACKAGE_ENTRIES: + return Response( + {"package_base64": f"Package exceeds {MAX_PACKAGE_ENTRIES} entries."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + metadata: dict = {} + if "problem.yaml" in names: + metadata = parse_flat_yaml( + package.read("problem.yaml").decode("utf-8", errors="replace") + ) + + en_statement, ro_statement, tex_title = _statement_from_zip(package) + tests = _tests_from_zip(package) + if not tests: + return Response( + {"package_base64": "No importable .in/.out test pairs found."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + yaml_name = str(metadata.get("name") or "").strip() + title = tex_title or yaml_name or f"Imported problem {len(tests)} tests" + limits = metadata.get("limits") + time_ms = 1000 + memory_kb = 262144 + if isinstance(limits, dict): + try: + time_ms = int(float(limits.get("time_seconds", 1)) * 1000) + except (TypeError, ValueError): + time_ms = 1000 + try: + memory_kb = int(limits.get("memory_limit_bytes", 262144)) // 1024 + except (TypeError, ValueError): + memory_kb = 262144 + + problem = Problem.objects.create( + title_i18n={"en": title, "ro": title}, + statement_i18n={ + "en": en_statement or "Imported package — statement pending.", + "ro": ro_statement or "", + }, + difficulty=Problem.DifficultyLevel.MEDIUM, + status=Problem.ProblemStatus.DRAFT, + author=user, + default_time_limit_ms=time_ms, + default_memory_limit_kb=memory_kb, + import_source=source, + source_contest=(data.get("source_contest") or "").strip(), + source_year=data.get("source_year"), + grade_group=(data.get("grade_group") or "").strip(), + ) + # ``bulk_create`` would skip per-row defaults/signals; the import + # path is low-volume, so explicit creates keep ordering exact. + imported_tests = tests[:MAX_IMPORT_TESTS] + created = 0 + for order, (_name, input_text, answer, is_sample) in enumerate(imported_tests): + TestCase.objects.create( + problem=problem, + input_data=input_text, + expected_output_data=answer, + is_sample=is_sample, + points=10, + order=order, + ) + created += 1 + + return Response( + { + "problem_id": problem.id, + "title": title, + "tests_imported": created, + "samples": sum(1 for t in imported_tests if t[3]), + "status": problem.status, + "import_source": source.name if source else "declared own content", + "note": "Imported as DRAFT — statements often need polish; submit for approval when ready.", + }, + status=status.HTTP_201_CREATED, + ) diff --git a/backend/problems/urls.py b/backend/problems/urls.py index bc7bc61c..a6f6f6bb 100644 --- a/backend/problems/urls.py +++ b/backend/problems/urls.py @@ -8,8 +8,12 @@ ) from .tooling import ( AssignmentSolutionsExportView, + GenerateOutputsView, + ImportPackageView, + OfflineBundleView, SampleTestsDownloadView, StressTestView, + ValidatorRunView, ) from .community import ( ArticleViewSet, @@ -54,6 +58,19 @@ ), # Wave C (JDG-11/13, UXP-3/4): authoring & integrity tooling. path("tools/stress/", StressTestView.as_view(), name="tools-stress"), + # JDG-11 remainder: validator runner, expected-output generation and + # the Kattis/Polygon package importer (archive lane, CNT-2-gated). + path("tools/validate/", ValidatorRunView.as_view(), name="tools-validate"), + path( + "tools/generate-outputs/", + GenerateOutputsView.as_view(), + name="tools-generate-outputs", + ), + path( + "tools/import-package/", + ImportPackageView.as_view(), + name="tools-import-package", + ), path( "tools/assignment-solutions//", AssignmentSolutionsExportView.as_view(), @@ -64,5 +81,11 @@ SampleTestsDownloadView.as_view(), name="problem-samples", ), + # RO-6: the offline study bundle (statement + samples as one PDF). + path( + "/offline-bundle/", + OfflineBundleView.as_view(), + name="problem-offline-bundle", + ), path("", include(router.urls)), ] diff --git a/backend/problems/views.py b/backend/problems/views.py index 7b4e58ac..6b6d3f30 100644 --- a/backend/problems/views.py +++ b/backend/problems/views.py @@ -261,6 +261,17 @@ def get_queryset(self): node_id = self.request.query_params.get("curriculum_node") if node_id: qs = qs.filter(curriculum_nodes__id=node_id) + # Archive browse axis (competitive roadmap RO-6): the olympiad + # archive's provenance fields are a first-class catalog filter — + # browse by edition year, grade group, or "archive problems only". + source_year = self.request.query_params.get("source_year") + if source_year: + qs = qs.filter(source_year=source_year) + grade_group = self.request.query_params.get("grade_group") + if grade_group: + qs = qs.filter(grade_group=grade_group) + if self.request.query_params.get("archive") in ("1", "true", "yes"): + qs = qs.exclude(source_contest="", source_year__isnull=True, grade_group="") # Catalog stats add-on (SPEC-GAPS D-B8): "X tried / Y solved" per # problem card. distinct=True keeps counts exact under the author # branch's .distinct(). @@ -278,6 +289,19 @@ def submit_for_approval(self, request, pk=None): problem = ( self.get_object() ) # ProblemObjectPermissions will check if user is author + # CNT-2/RO-6 rights gate: an imported problem (ledger row attached) + # may not even enter the approval queue unless the covering row is + # GRANTED — the pipeline is rights-checked by construction. + if problem.import_source_id and not problem.import_source.allows_publication: + return Response( + { + "status": ( + f"import source '{problem.import_source.name}' is " + f"{problem.import_source.status}; publication blocked" + ) + }, + status=status.HTTP_403_FORBIDDEN, + ) if problem.status == Problem.ProblemStatus.DRAFT: problem.status = Problem.ProblemStatus.PENDING_APPROVAL problem.verifier = None # Clear verifier when resubmitting @@ -296,6 +320,18 @@ def submit_for_approval(self, request, pk=None): @action(detail=True, methods=["post"], url_path="approve") def approve_problem(self, request, pk=None): # Renamed from 'approve' problem = self.get_object() # IsProblemVerifier permission applied + # CNT-2/RO-6: the GRANTED check re-runs at approve time — a ledger + # row may have expired between submission and review. + if problem.import_source_id and not problem.import_source.allows_publication: + return Response( + { + "status": ( + f"import source '{problem.import_source.name}' is " + f"{problem.import_source.status}; cannot approve" + ) + }, + status=status.HTTP_403_FORBIDDEN, + ) if problem.status == Problem.ProblemStatus.PENDING_APPROVAL: problem.status = Problem.ProblemStatus.APPROVED problem.verifier = request.user diff --git a/backend/public_api/views.py b/backend/public_api/views.py index 6eba66df..74470835 100644 --- a/backend/public_api/views.py +++ b/backend/public_api/views.py @@ -165,6 +165,10 @@ def get(self, request): "tags": [t.slug for t in problem.tags.all()], "time_limit_ms": problem.default_time_limit_ms, "memory_limit_kb": problem.default_memory_limit_kb, + # RO-6 archive provenance (attribution is public). + "source_contest": problem.source_contest, + "source_year": problem.source_year, + "grade_group": problem.grade_group, } for problem in page ], @@ -196,6 +200,10 @@ def get(self, request, pk): "memory_limit_kb": problem.default_memory_limit_kb, "io_mode": problem.io_mode, "problem_type": problem.problem_type, + # RO-6 archive provenance (attribution is public). + "source_contest": problem.source_contest, + "source_year": problem.source_year, + "grade_group": problem.grade_group, # Statements only — test data and checkers never serialize. "has_editorial": hasattr(problem, "editorial"), } diff --git a/e2e/lib/realStack.ts b/e2e/lib/realStack.ts index b1e73909..b5f35b2d 100644 --- a/e2e/lib/realStack.ts +++ b/e2e/lib/realStack.ts @@ -106,7 +106,16 @@ async function seedOnce(): Promise<{ twoSumId: number; reverseId: number }> { } } - const createProblem = async (title: string, difficulty: string, statement: string) => { + // provenance (optional per-problem) powers the [RO-6] archive-axis + // spec on the real stack exactly like the mock fixture. Spreading the + // optional object directly (no `?? {}` fallback) — oxlint's + // no-useless-fallback-in-spread is denied at the quality gate. + const createProblem = async ( + title: string, + difficulty: string, + statement: string, + provenance?: { source_contest: string; source_year: number; grade_group: string }, + ) => { const { status, body } = await api('/problems/problems/', { method: 'POST', token, @@ -119,6 +128,7 @@ async function seedOnce(): Promise<{ twoSumId: number; reverseId: number }> { default_memory_limit_kb: 65536, allowed_languages: ['python3'], tags: [], + ...provenance, }), }); if (status !== 201) throw new Error(`seed ${title} -> ${status}: ${JSON.stringify(body)}`); @@ -129,6 +139,7 @@ async function seedOnce(): Promise<{ twoSumId: number; reverseId: number }> { SEED_TITLES.twoSum, 'EASY', 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.', + { source_contest: 'OJI 2024', source_year: 2024, grade_group: 'IX-X' }, ); const reverseId = await createProblem(SEED_TITLES.reverse, 'MEDIUM', 'Reverse the input string.'); diff --git a/e2e/mock-api/server.mjs b/e2e/mock-api/server.mjs index 304003d6..d237e54f 100644 --- a/e2e/mock-api/server.mjs +++ b/e2e/mock-api/server.mjs @@ -42,6 +42,10 @@ const MOCK_PROBLEMS = [ title_i18n: { en: 'Two Sum' }, difficulty: 'EASY', status: 'APPROVED', + // [roadmap RO-6] archive provenance for the browse-axis e2e lane. + source_contest: 'OJI 2024', + source_year: 2024, + grade_group: 'IX-X', statement_i18n: { en: 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.', }, @@ -739,7 +743,22 @@ const server = createServer((req, res) => { } if (req.method === 'GET' && path === '/api/v1/problems/problems/') { - json(res, 200, paged(MOCK_PROBLEMS)); + // [roadmap RO-6] honor the archive browse axis so mock-mode e2e walks + // the same server-filtered loop as the real backend. + let results = MOCK_PROBLEMS; + const archive = requestUrl.searchParams.get('archive'); + if (archive === '1') { + results = results.filter((problem) => problem.source_contest || problem.source_year); + } + const year = requestUrl.searchParams.get('source_year'); + if (year) { + results = results.filter((problem) => String(problem.source_year) === year); + } + const group = requestUrl.searchParams.get('grade_group'); + if (group) { + results = results.filter((problem) => problem.grade_group === group); + } + json(res, 200, paged(results)); return; } diff --git a/e2e/problems.spec.ts b/e2e/problems.spec.ts index 64b2de58..90533d3c 100644 --- a/e2e/problems.spec.ts +++ b/e2e/problems.spec.ts @@ -154,3 +154,32 @@ test('home shows the problem of the day [UXP-5a]', async ({ page }) => { await page?.goto('/'); await expect(page?.getByText('Problem of the day'))?.toBeVisible(); }); + +test('catalog archive axis filters server-side and shows provenance [RO-6]', async ({ page }) => { + await page?.goto('/problems'); + + // Provenance chip rides the archived row (seeded ids differ per mode). + await expect(page?.getByTestId(`provenance-${seeded.twoSumId}`))?.toContainText('OJI 2024'); + + // Toggling the axis deep-links ?archive=1 and the server-filtered list + // keeps only the provenance-carrying problems (mock honors the params). + await page?.getByTestId('archive-toggle').click(); + await expect(page)?.toHaveURL(/archive=1/); + await expect(page?.getByRole('link', { name: 'Two Sum' }))?.toBeVisible(); + const plainRows = await page + ?.getByRole('link', { name: 'Reverse String' }) + .count() + .catch(() => 0); + expect(plainRows ?? 0).toBe(0); + + // The year refinement appears with the axis active and narrows to the + // 2024 edition; clearing the year restores the full archive listing. + const year = page?.getByTestId('archive-year'); + await year?.fill('2024'); + await year?.blur(); + await expect(page)?.toHaveURL(/source_year=2024/); + await expect(page?.getByRole('link', { name: 'Two Sum' }))?.toBeVisible(); + await year?.fill(''); + await year?.blur(); + await expect(page)?.not.toHaveURL(/source_year/); +}); diff --git a/public/locales/ar.json b/public/locales/ar.json index d215157a..bd011b5f 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -1178,5 +1178,9 @@ "reset_password_done": "تم تحديث كلمة المرور. يمكنك تسجيل الدخول الآن.", "reset_password_invalid": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.", "reset_password_request_new": "اطلب رابطًا جديدًا", - "reset_password_mismatch": "كلمتا المرور غير متطابقتين." + "reset_password_mismatch": "كلمتا المرور غير متطابقتين.", + "archive_filter_toggle": "أرشيف الأولمبياد", + "archive_year_label": "سنة النسخة", + "archive_grade_label": "مجموعة الصفوف", + "archive_all_groups": "كل المجموعات" } diff --git a/public/locales/bg.json b/public/locales/bg.json index ab6d2ea5..5f217d08 100644 --- a/public/locales/bg.json +++ b/public/locales/bg.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Паролата е обновена. Можете да влезете сега.", "reset_password_invalid": "Този линк за възстановяване е невалиден или изтече. Заявете нов.", "reset_password_request_new": "Заявете нов линк", - "reset_password_mismatch": "Двете пароли не съвпадат." + "reset_password_mismatch": "Двете пароли не съвпадат.", + "archive_filter_toggle": "Архив на олимпиадите", + "archive_year_label": "Година на изданието", + "archive_grade_label": "Група класове", + "archive_all_groups": "Всички групи" } diff --git a/public/locales/ca.json b/public/locales/ca.json index 3e123494..0df4f32a 100644 --- a/public/locales/ca.json +++ b/public/locales/ca.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Contrasenya actualitzada. Ja pots iniciar sessió.", "reset_password_invalid": "Aquest enllaç de restabliment no és vàlid o ha caducat. Demana'n un de nou.", "reset_password_request_new": "Demana un enllaç nou", - "reset_password_mismatch": "Les dues contrasenyes no coincideixen." + "reset_password_mismatch": "Les dues contrasenyes no coincideixen.", + "archive_filter_toggle": "Arxiu d'olimpíades", + "archive_year_label": "Any de l'edició", + "archive_grade_label": "Grup de curs", + "archive_all_groups": "Tots els grups" } diff --git a/public/locales/de.json b/public/locales/de.json index fa603475..a7e03a4e 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Passwort aktualisiert. Du kannst dich jetzt anmelden.", "reset_password_invalid": "Dieser Reset-Link ist ungültig oder abgelaufen. Fordere einen neuen an.", "reset_password_request_new": "Neuen Link anfordern", - "reset_password_mismatch": "Die beiden Passwörter stimmen nicht überein." + "reset_password_mismatch": "Die beiden Passwörter stimmen nicht überein.", + "archive_filter_toggle": "Olympiade-Archiv", + "archive_year_label": "Jahrgang", + "archive_grade_label": "Klassenstufe", + "archive_all_groups": "Alle Stufen" } diff --git a/public/locales/el.json b/public/locales/el.json index ef1d4335..c046aff9 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Ο κωδικός ενημερώθηκε. Μπορείτε να συνδεθείτε τώρα.", "reset_password_invalid": "Αυτός ο σύνδεσμος επαναφοράς είναι μη έγκυρος ή έχει λήξει. Ζητήστε νέο.", "reset_password_request_new": "Ζητήστε νέο σύνδεσμο", - "reset_password_mismatch": "Οι δύο κωδικοί δεν ταιριάζουν." + "reset_password_mismatch": "Οι δύο κωδικοί δεν ταιριάζουν.", + "archive_filter_toggle": "Αρχείο Ολυμπιάδων", + "archive_year_label": "Έτος διεξαγωγής", + "archive_grade_label": "Ομάδα τάξεων", + "archive_all_groups": "Όλες οι ομάδες" } diff --git a/public/locales/en.json b/public/locales/en.json index f2241f0d..0a845723 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Password updated. You can log in with it now.", "reset_password_invalid": "This reset link is invalid or has expired. Request a new one.", "reset_password_request_new": "Request a new link", - "reset_password_mismatch": "The two passwords do not match." + "reset_password_mismatch": "The two passwords do not match.", + "archive_filter_toggle": "Olympiad archive", + "archive_year_label": "Edition year", + "archive_grade_label": "Grade group", + "archive_all_groups": "All groups" } diff --git a/public/locales/es.json b/public/locales/es.json index 99bc717f..8fe10b83 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Contraseña actualizada. Ya puedes iniciar sesión.", "reset_password_invalid": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.", "reset_password_request_new": "Solicitar un enlace nuevo", - "reset_password_mismatch": "Las dos contraseñas no coinciden." + "reset_password_mismatch": "Las dos contraseñas no coinciden.", + "archive_filter_toggle": "Archivo de olimpiadas", + "archive_year_label": "Año de la edición", + "archive_grade_label": "Grupo de grado", + "archive_all_groups": "Todos los grupos" } diff --git a/public/locales/fa.json b/public/locales/fa.json index 16621891..b8dfc691 100644 --- a/public/locales/fa.json +++ b/public/locales/fa.json @@ -1178,5 +1178,9 @@ "reset_password_done": "رمز عبور به‌روزرسانی شد. اکنون می‌توانید وارد شوید.", "reset_password_invalid": "این پیوند بازنشانی نامعتبر است یا منقضی شده است. پیوند جدید بگیرید.", "reset_password_request_new": "درخواست پیوند جدید", - "reset_password_mismatch": "دو رمز عبور با هم مطابقت ندارند." + "reset_password_mismatch": "دو رمز عبور با هم مطابقت ندارند.", + "archive_filter_toggle": "آرشیو المپیاد", + "archive_year_label": "سال برگزاری", + "archive_grade_label": "گروه پایه", + "archive_all_groups": "همه گروه‌ها" } diff --git a/public/locales/fr.json b/public/locales/fr.json index 71c9b5b3..783bde39 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Mot de passe mis à jour. Vous pouvez vous connecter.", "reset_password_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.", "reset_password_request_new": "Demander un nouveau lien", - "reset_password_mismatch": "Les deux mots de passe ne correspondent pas." + "reset_password_mismatch": "Les deux mots de passe ne correspondent pas.", + "archive_filter_toggle": "Archive des olympiades", + "archive_year_label": "Année de l'édition", + "archive_grade_label": "Groupe de niveau", + "archive_all_groups": "Tous les groupes" } diff --git a/public/locales/hi.json b/public/locales/hi.json index 8fecef1d..be3bb9a9 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -1178,5 +1178,9 @@ "reset_password_done": "पासवर्ड अपडेट हो गया। अब आप लॉगिन कर सकते हैं।", "reset_password_invalid": "यह रीसेट लिंक अमान्य है या समाप्त हो गया है। नया लिंक का अनुरोध करें।", "reset_password_request_new": "नया लिंक का अनुरोध करें", - "reset_password_mismatch": "दोनों पासवर्ड मेल नहीं खाते।" + "reset_password_mismatch": "दोनों पासवर्ड मेल नहीं खाते।", + "archive_filter_toggle": "ओलंपियाड संग्रह", + "archive_year_label": "संस्करण वर्ष", + "archive_grade_label": "कक्षा समूह", + "archive_all_groups": "सभी समूह" } diff --git a/public/locales/hr.json b/public/locales/hr.json index f741f86e..aad5eecc 100644 --- a/public/locales/hr.json +++ b/public/locales/hr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Lozinka je ažurirana. Sada se možete prijaviti.", "reset_password_invalid": "Ova poveznica za resetiranje nevažeća je ili je istekla. Zatražite novu.", "reset_password_request_new": "Zatraži novu poveznicu", - "reset_password_mismatch": "Dvije lozinke se ne podudaraju." + "reset_password_mismatch": "Dvije lozinke se ne podudaraju.", + "archive_filter_toggle": "Arhiva olimpijada", + "archive_year_label": "Godina izdanja", + "archive_grade_label": "Skupina razreda", + "archive_all_groups": "Sve skupine" } diff --git a/public/locales/hu.json b/public/locales/hu.json index 0cb2a712..53d04acf 100644 --- a/public/locales/hu.json +++ b/public/locales/hu.json @@ -1178,5 +1178,9 @@ "reset_password_done": "A jelszó frissítve. Most már bejelentkezhetsz.", "reset_password_invalid": "Ez a visszaállító link érvénytelen vagy lejárt. Kérj újat.", "reset_password_request_new": "Új link kérése", - "reset_password_mismatch": "A két jelszó nem egyezik." + "reset_password_mismatch": "A két jelszó nem egyezik.", + "archive_filter_toggle": "Olimpiai archívum", + "archive_year_label": "Kiadás éve", + "archive_grade_label": "Évfolyamcsoport", + "archive_all_groups": "Minden csoport" } diff --git a/public/locales/id.json b/public/locales/id.json index 92c2eb3a..3db598a3 100644 --- a/public/locales/id.json +++ b/public/locales/id.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Kata sandi diperbarui. Anda bisa login sekarang.", "reset_password_invalid": "Tautan pengaturan ulang ini tidak valid atau telah kedaluwarsa. Minta yang baru.", "reset_password_request_new": "Minta tautan baru", - "reset_password_mismatch": "Dua kata sandi tidak cocok." + "reset_password_mismatch": "Dua kata sandi tidak cocok.", + "archive_filter_toggle": "Arsip olimpiade", + "archive_year_label": "Tahun penyelenggaraan", + "archive_grade_label": "Kelompok kelas", + "archive_all_groups": "Semua kelompok" } diff --git a/public/locales/it.json b/public/locales/it.json index 45157468..9ca65d0c 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Password aggiornata. Ora puoi accedere.", "reset_password_invalid": "Questo link di reimpostazione non è valido o è scaduto. Richiedine uno nuovo.", "reset_password_request_new": "Richiedi un nuovo link", - "reset_password_mismatch": "Le due password non coincidono." + "reset_password_mismatch": "Le due password non coincidono.", + "archive_filter_toggle": "Archivio delle olimpiadi", + "archive_year_label": "Anno dell'edizione", + "archive_grade_label": "Gruppo di classe", + "archive_all_groups": "Tutti i gruppi" } diff --git a/public/locales/ja.json b/public/locales/ja.json index ea15b345..206ffb62 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -1178,5 +1178,9 @@ "reset_password_done": "パスワードを更新しました。今すぐログインできます。", "reset_password_invalid": "このリセット用リンクは無効か期限切れです。新しいリンクを請求してください。", "reset_password_request_new": "新しいリンクを請求", - "reset_password_mismatch": "2つのパスワードが一致しません。" + "reset_password_mismatch": "2つのパスワードが一致しません。", + "archive_filter_toggle": "オリンピアードアーカイブ", + "archive_year_label": "開催年", + "archive_grade_label": "学年グループ", + "archive_all_groups": "すべてのグループ" } diff --git a/public/locales/kk.json b/public/locales/kk.json index 36f7a994..26ab397d 100644 --- a/public/locales/kk.json +++ b/public/locales/kk.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Құпиясөз жаңартылды. Енді кіре аласыз.", "reset_password_invalid": "Бұл қалпына келтіру сілтемесі жарамсыз немесе мерзімі өтіп кеткен. Жаңасын сұраңыз.", "reset_password_request_new": "Жаңа сілтеме сұрау", - "reset_password_mismatch": "Екі құпиясөз сәйкес емес." + "reset_password_mismatch": "Екі құпиясөз сәйкес емес.", + "archive_filter_toggle": "Олимпиада мұрағаты", + "archive_year_label": "Өткізілген жылы", + "archive_grade_label": "Сынып тобы", + "archive_all_groups": "Барлық топтар" } diff --git a/public/locales/ko.json b/public/locales/ko.json index 193ffa92..fdbebc43 100644 --- a/public/locales/ko.json +++ b/public/locales/ko.json @@ -1178,5 +1178,9 @@ "reset_password_done": "비밀번호가 업데이트되었습니다. 지금 로그인할 수 있습니다.", "reset_password_invalid": "이 재설정 링크는 유효하지 않거나 만료되었습니다. 새 링크를 요청하세요.", "reset_password_request_new": "새 링크 요청", - "reset_password_mismatch": "두 비밀번호가 일치하지 않습니다." + "reset_password_mismatch": "두 비밀번호가 일치하지 않습니다.", + "archive_filter_toggle": "올림피아드 아카이브", + "archive_year_label": "개최 연도", + "archive_grade_label": "학년 그룹", + "archive_all_groups": "모든 그룹" } diff --git a/public/locales/pl.json b/public/locales/pl.json index 865b5f54..2c6c7dda 100644 --- a/public/locales/pl.json +++ b/public/locales/pl.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Hasło zostało zaktualizowane. Możesz się teraz zalogować.", "reset_password_invalid": "Ten link do resetowania jest nieprawidłowy lub wygasł. Poproś o nowy.", "reset_password_request_new": "Poproś o nowy link", - "reset_password_mismatch": "Hasła nie są identyczne." + "reset_password_mismatch": "Hasła nie są identyczne.", + "archive_filter_toggle": "Archiwum olimpiad", + "archive_year_label": "Rocznik", + "archive_grade_label": "Grupa klas", + "archive_all_groups": "Wszystkie grupy" } diff --git a/public/locales/pt.json b/public/locales/pt.json index 13efb431..fd862e9b 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Senha atualizada. Você já pode entrar.", "reset_password_invalid": "Este link de redefinição é inválido ou expirou. Solicite um novo.", "reset_password_request_new": "Solicitar novo link", - "reset_password_mismatch": "As duas senhas não coincidem." + "reset_password_mismatch": "As duas senhas não coincidem.", + "archive_filter_toggle": "Arquivo de olimpíadas", + "archive_year_label": "Ano da edição", + "archive_grade_label": "Grupo de série", + "archive_all_groups": "Todos os grupos" } diff --git a/public/locales/ro.json b/public/locales/ro.json index 44db99fc..1c46f630 100644 --- a/public/locales/ro.json +++ b/public/locales/ro.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Parola a fost actualizată. Te poți autentifica acum.", "reset_password_invalid": "Acest link de resetare este invalid sau a expirat. Cere unul nou.", "reset_password_request_new": "Cere un link nou", - "reset_password_mismatch": "Cele două parole nu coincid." + "reset_password_mismatch": "Cele două parole nu coincid.", + "archive_filter_toggle": "Arhiva olimpiadelor", + "archive_year_label": "Anul ediției", + "archive_grade_label": "Grupa de clase", + "archive_all_groups": "Toate grupele" } diff --git a/public/locales/ru.json b/public/locales/ru.json index 6bbd635a..95634413 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Пароль обновлён. Теперь вы можете войти.", "reset_password_invalid": "Эта ссылка для сброса недействительна или истекла. Запросите новую.", "reset_password_request_new": "Запросить новую ссылку", - "reset_password_mismatch": "Пароли не совпадают." + "reset_password_mismatch": "Пароли не совпадают.", + "archive_filter_toggle": "Архив олимпиад", + "archive_year_label": "Год проведения", + "archive_grade_label": "Группа классов", + "archive_all_groups": "Все группы" } diff --git a/public/locales/sr-Latn.json b/public/locales/sr-Latn.json index ea670ec5..3e47d84a 100644 --- a/public/locales/sr-Latn.json +++ b/public/locales/sr-Latn.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Lozinka je ažurirana. Sada se možete prijaviti.", "reset_password_invalid": "Ovaj link za resetovanje je nevažeći ili je istekao. Zatražite novi.", "reset_password_request_new": "Zatraži novi link", - "reset_password_mismatch": "Dve lozinke se ne poklapaju." + "reset_password_mismatch": "Dve lozinke se ne poklapaju.", + "archive_filter_toggle": "Arhiva olimpijada", + "archive_year_label": "Godina izdanja", + "archive_grade_label": "Grupa razreda", + "archive_all_groups": "Sve grupe" } diff --git a/public/locales/th.json b/public/locales/th.json index a594b3de..2043327b 100644 --- a/public/locales/th.json +++ b/public/locales/th.json @@ -1178,5 +1178,9 @@ "reset_password_done": "อัปเดตรหัสผ่านแล้ว คุณเข้าสู่ระบบได้เลย", "reset_password_invalid": "ลิงก์รีเซ็ตนี้ไม่ถูกต้องหรือหมดอายุแล้ว ขอลิงก์ใหม่ได้เลย", "reset_password_request_new": "ขอลิงก์ใหม่", - "reset_password_mismatch": "รหัสผ่านทั้งสองไม่ตรงกัน" + "reset_password_mismatch": "รหัสผ่านทั้งสองไม่ตรงกัน", + "archive_filter_toggle": "คลังข้อสอบโอลิมปิก", + "archive_year_label": "ปีที่จัด", + "archive_grade_label": "กลุ่มชั้นเรียน", + "archive_all_groups": "ทุกกลุ่ม" } diff --git a/public/locales/tr.json b/public/locales/tr.json index d9114cde..e2bd6a43 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Şifre güncellendi. Artık giriş yapabilirsin.", "reset_password_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi dolmuş. Yeni bir tane iste.", "reset_password_request_new": "Yeni bağlantı iste", - "reset_password_mismatch": "İki şifre birbiriyle uyuşmuyor." + "reset_password_mismatch": "İki şifre birbiriyle uyuşmuyor.", + "archive_filter_toggle": "Olimpiyat arşivi", + "archive_year_label": "Yıl", + "archive_grade_label": "Sınıf grubu", + "archive_all_groups": "Tüm gruplar" } diff --git a/public/locales/uk.json b/public/locales/uk.json index 7ec60246..a8d39e65 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Пароль оновлено. Тепер ви можете увійти.", "reset_password_invalid": "Це посилання для скидання недійсне або застаріле. Запросіть нове.", "reset_password_request_new": "Запросити нове посилання", - "reset_password_mismatch": "Паролі не співпадають." + "reset_password_mismatch": "Паролі не співпадають.", + "archive_filter_toggle": "Архів олімпіад", + "archive_year_label": "Рік проведення", + "archive_grade_label": "Група класів", + "archive_all_groups": "Усі групи" } diff --git a/public/locales/vi.json b/public/locales/vi.json index 3cfcdb7a..63d5b66b 100644 --- a/public/locales/vi.json +++ b/public/locales/vi.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Đã cập nhật mật khẩu. Bạn có thể đăng nhập ngay bây giờ.", "reset_password_invalid": "Liên kết đặt lại này không hợp lệ hoặc đã hết hạn. Hãy yêu cầu liên kết mới.", "reset_password_request_new": "Yêu cầu liên kết mới", - "reset_password_mismatch": "Hai mật khẩu không khớp." + "reset_password_mismatch": "Hai mật khẩu không khớp.", + "archive_filter_toggle": "Kho đề Olympic", + "archive_year_label": "Năm tổ chức", + "archive_grade_label": "Nhóm lớp", + "archive_all_groups": "Tất cả các nhóm" } diff --git a/public/locales/zh-Hans.json b/public/locales/zh-Hans.json index 5e3fa41f..12c38778 100644 --- a/public/locales/zh-Hans.json +++ b/public/locales/zh-Hans.json @@ -1178,5 +1178,9 @@ "reset_password_done": "密码已更新,现在即可登录。", "reset_password_invalid": "该重置链接无效或已过期,请重新申请。", "reset_password_request_new": "申请新链接", - "reset_password_mismatch": "两次输入的密码不一致。" + "reset_password_mismatch": "两次输入的密码不一致。", + "archive_filter_toggle": "奥赛题库", + "archive_year_label": "届次年份", + "archive_grade_label": "年级组", + "archive_all_groups": "全部组别" } diff --git a/public/locales/zh-Hant.json b/public/locales/zh-Hant.json index 3c7f3e31..ff6a6158 100644 --- a/public/locales/zh-Hant.json +++ b/public/locales/zh-Hant.json @@ -1178,5 +1178,9 @@ "reset_password_done": "密碼已更新,現在即可登入。", "reset_password_invalid": "此重設連結無效或已過期,請重新申請。", "reset_password_request_new": "申請新連結", - "reset_password_mismatch": "兩次輸入的密碼不一致。" + "reset_password_mismatch": "兩次輸入的密碼不一致。", + "archive_filter_toggle": "奧賽題庫", + "archive_year_label": "屆次年份", + "archive_grade_label": "年級組", + "archive_all_groups": "全部組別" } diff --git a/src/app/_lib/public-messages.ts b/src/app/_lib/public-messages.ts index 38d97b9d..fb676c77 100644 --- a/src/app/_lib/public-messages.ts +++ b/src/app/_lib/public-messages.ts @@ -123,6 +123,11 @@ export const PUBLIC_KEYS: string[] = [ 'problems_load_error', 'hide_solved_toggle', 'curriculum_label', + // [roadmap RO-6] The archive browse axis (toggle + year/group filters). + 'archive_filter_toggle', + 'archive_year_label', + 'archive_grade_label', + 'archive_all_groups', 'tags_label', 'solved_column_th', 'solved_aria', diff --git a/src/app/problems/ProblemsListClient.test.tsx b/src/app/problems/ProblemsListClient.test.tsx index b323321a..77a34538 100644 --- a/src/app/problems/ProblemsListClient.test.tsx +++ b/src/app/problems/ProblemsListClient.test.tsx @@ -381,3 +381,95 @@ describe('Wave A: tag facet [F4] + solved indicators [F5] + curriculum axis [EDU ).toHaveAttribute('href', '/start'); }); }); + +describe('ProblemsListClient archive browse axis [RO-6]', () => { + it('renders a provenance chip on archived problems only', () => { + const catalog = [ + problem({ + id: 9, + title_i18n: { en: 'OJI Sum' }, + source_contest: 'OJI 2024', + source_year: 2024, + grade_group: 'IX-X', + }), + problem({ id: 10, title_i18n: { en: 'Plain' } }), + ]; + render( + , + ); + expect(screen.getByTestId('provenance-9').textContent).toBe('OJI 2024 · 2024 · IX-X'); + expect(screen.queryByTestId('provenance-10')).not.toBeInTheDocument(); + }); + + it('toggles ?archive=1 into the URL', () => { + render( + , + ); + fireEvent.click(screen.getByTestId('archive-toggle')); + expect(mocks.replace).toHaveBeenCalledWith('/problems?archive=1', { scroll: false }); + }); + + it('deletes the archive param when toggling an active filter off', () => { + mocks.params = new URLSearchParams('archive=1'); + render( + , + ); + fireEvent.click(screen.getByTestId('archive-toggle')); + expect(mocks.replace).toHaveBeenCalledWith('/problems', { scroll: false }); + }); + + it('shows year/group refinements once the axis is active and syncs them', () => { + mocks.params = new URLSearchParams('archive=1'); + render( + , + ); + const year = screen.getByTestId('archive-year'); + fireEvent.change(year, { target: { value: '2024' } }); + fireEvent.blur(year); + expect(mocks.replace).toHaveBeenCalledWith('/problems?archive=1&source_year=2024', { + scroll: false, + }); + fireEvent.change(screen.getByTestId('archive-group'), { target: { value: 'IX-X' } }); + expect(mocks.replace).toHaveBeenCalledWith('/problems?archive=1&grade_group=IX-X', { + scroll: false, + }); + }); + + it('keeps the refinements hidden while the axis is inactive', () => { + render( + , + ); + expect(screen.queryByTestId('archive-year')).not.toBeInTheDocument(); + expect(screen.queryByTestId('archive-group')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/problems/ProblemsListClient.tsx b/src/app/problems/ProblemsListClient.tsx index db7b2ff7..844ca5ae 100644 --- a/src/app/problems/ProblemsListClient.tsx +++ b/src/app/problems/ProblemsListClient.tsx @@ -179,6 +179,12 @@ export default function ProblemsListClient({ // [EDU-9] chapter drill-down runs through the server (?curriculum=), // so the client only needs the current value (not local state). const urlCurriculum = searchParams.get('curriculum') ?? ''; + // [RO-6] The archive browse axis is server-filtered the same way: + // ?archive=1 + optional ?source_year= / ?grade_group= refinements. + const urlArchive = searchParams.get('archive') === '1'; + const urlYear = searchParams.get('source_year') ?? ''; + const urlGroup = searchParams.get('grade_group') ?? ''; + const [yearDraft, setYearDraft] = useState(urlYear); const [search, setSearch] = useState(urlSearch); const [difficultyFilter, setDifficultyFilter] = useState(urlDifficulty); @@ -196,7 +202,8 @@ export default function ProblemsListClient({ setDifficultyFilter(urlDifficulty); setStatusFilter(urlStatus); setTagFilter(new Set(urlTags.split(',').filter(Boolean).map(Number))); - }, [urlSearch, urlDifficulty, urlStatus, urlTags]); + setYearDraft(urlYear); + }, [urlSearch, urlDifficulty, urlStatus, urlTags, urlYear]); // Push filter changes into the URL (replace, no scroll jump). [F3] const syncUrl = (next: { @@ -205,6 +212,9 @@ export default function ProblemsListClient({ status?: string; tags?: string; curriculum?: string; + archive?: boolean; + sourceYear?: string; + gradeGroup?: string; }) => { const params = new URLSearchParams(searchParams.toString()); const setOrDelete = (name: string, value: string | undefined) => { @@ -222,6 +232,20 @@ export default function ProblemsListClient({ 'curriculum', next.curriculum !== undefined ? next.curriculum : urlCurriculum || undefined, ); + // [RO-6] archive axis: the toggle writes/deletes archive=1 itself so + // the boolean round-trips exactly (never the string "false"). + if (next.archive !== undefined) { + if (next.archive) params.set('archive', '1'); + else params.delete('archive'); + } + setOrDelete( + 'source_year', + next.sourceYear !== undefined ? next.sourceYear : urlYear || undefined, + ); + setOrDelete( + 'grade_group', + next.gradeGroup !== undefined ? next.gradeGroup : urlGroup || undefined, + ); const qs = params.toString(); router.replace(qs ? `/problems?${qs}` : '/problems', { scroll: false }); }; @@ -626,6 +650,102 @@ export default function ProblemsListClient({ )} + {/* [roadmap RO-6] Archive browse axis: olympiad-provenance filters. + Server-filtered like the curriculum axis (?archive=1 + optional + year / grade-group refinements answered by the backend). */} +
+ + {(urlArchive || urlYear || urlGroup) && ( + <> + + + + )} +
+ {/* [F4] Tag facet */} {allTags.length > 0 && (
+ {/* [RO-6] archive provenance chip — attribution is + public; also the visible hint the axis filters. */} + {(problem.source_contest || problem.source_year) && ( + + {[problem.source_contest, problem.source_year, problem.grade_group] + .filter(Boolean) + .join(' · ')} + + )} > { - const nodeQs = filters.curriculumNode ? `&curriculum_node=${filters.curriculumNode}` : ''; - return fetchJson>( - `/problems/problems/?page=${page}&page_size=${CATALOG_PAGE_SIZE}${nodeQs}`, - ); + // [roadmap RO-6] The archive browse axis filters the SERVER fetch, same + // as the curriculum axis, so deep links (?archive=1&source_year=2024) + // are answered by the backend, not the client's loaded-page window. + const params = new URLSearchParams({ + page: String(page), + page_size: String(CATALOG_PAGE_SIZE), + }); + if (filters.curriculumNode) params.set('curriculum_node', String(filters.curriculumNode)); + if (filters.archive) params.set('archive', '1'); + if (filters.sourceYear) params.set('source_year', String(filters.sourceYear)); + if (filters.gradeGroup) params.set('grade_group', filters.gradeGroup); + return fetchJson>(`/problems/problems/?${params.toString()}`); } export interface CurriculumChapter { diff --git a/src/app/problems/page.tsx b/src/app/problems/page.tsx index 7addda25..6dfc9be8 100644 --- a/src/app/problems/page.tsx +++ b/src/app/problems/page.tsx @@ -36,12 +36,25 @@ export async function generateMetadata(): Promise { } interface ProblemsListPageProps { - searchParams: Promise<{ curriculum?: string }>; + searchParams: Promise<{ + curriculum?: string; + archive?: string; + source_year?: string; + grade_group?: string; + }>; } export default async function ProblemsListPage({ searchParams }: ProblemsListPageProps) { const locale = await getLocale(); const { curriculum: curriculumParam } = await searchParams; + // [roadmap RO-6] The archive browse axis: ?archive=1 (+ optional year / + // grade-group refinements) filters the server fetch like ?curriculum=. + const archiveParam = await searchParams; + const archiveFilters = { + archive: archiveParam.archive === '1' || archiveParam.archive === 'true', + sourceYear: archiveParam.source_year, + gradeGroup: archiveParam.grade_group, + }; let problems: Awaited>['results'] = []; let total = 0; @@ -61,7 +74,7 @@ export default async function ProblemsListPage({ searchParams }: ProblemsListPag try { const [firstPage] = await Promise.all([ - getProblemsPage(1, { curriculumNode: curriculumParam }), + getProblemsPage(1, { curriculumNode: curriculumParam, ...archiveFilters }), curriculumFetch, ]); problems = firstPage.results; diff --git a/src/locales/ar.json b/src/locales/ar.json index d215157a..bd011b5f 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1178,5 +1178,9 @@ "reset_password_done": "تم تحديث كلمة المرور. يمكنك تسجيل الدخول الآن.", "reset_password_invalid": "رابط إعادة التعيين غير صالح أو منتهي الصلاحية. اطلب رابطًا جديدًا.", "reset_password_request_new": "اطلب رابطًا جديدًا", - "reset_password_mismatch": "كلمتا المرور غير متطابقتين." + "reset_password_mismatch": "كلمتا المرور غير متطابقتين.", + "archive_filter_toggle": "أرشيف الأولمبياد", + "archive_year_label": "سنة النسخة", + "archive_grade_label": "مجموعة الصفوف", + "archive_all_groups": "كل المجموعات" } diff --git a/src/locales/bg.json b/src/locales/bg.json index ab6d2ea5..5f217d08 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Паролата е обновена. Можете да влезете сега.", "reset_password_invalid": "Този линк за възстановяване е невалиден или изтече. Заявете нов.", "reset_password_request_new": "Заявете нов линк", - "reset_password_mismatch": "Двете пароли не съвпадат." + "reset_password_mismatch": "Двете пароли не съвпадат.", + "archive_filter_toggle": "Архив на олимпиадите", + "archive_year_label": "Година на изданието", + "archive_grade_label": "Група класове", + "archive_all_groups": "Всички групи" } diff --git a/src/locales/ca.json b/src/locales/ca.json index 3e123494..0df4f32a 100644 --- a/src/locales/ca.json +++ b/src/locales/ca.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Contrasenya actualitzada. Ja pots iniciar sessió.", "reset_password_invalid": "Aquest enllaç de restabliment no és vàlid o ha caducat. Demana'n un de nou.", "reset_password_request_new": "Demana un enllaç nou", - "reset_password_mismatch": "Les dues contrasenyes no coincideixen." + "reset_password_mismatch": "Les dues contrasenyes no coincideixen.", + "archive_filter_toggle": "Arxiu d'olimpíades", + "archive_year_label": "Any de l'edició", + "archive_grade_label": "Grup de curs", + "archive_all_groups": "Tots els grups" } diff --git a/src/locales/de.json b/src/locales/de.json index fa603475..a7e03a4e 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Passwort aktualisiert. Du kannst dich jetzt anmelden.", "reset_password_invalid": "Dieser Reset-Link ist ungültig oder abgelaufen. Fordere einen neuen an.", "reset_password_request_new": "Neuen Link anfordern", - "reset_password_mismatch": "Die beiden Passwörter stimmen nicht überein." + "reset_password_mismatch": "Die beiden Passwörter stimmen nicht überein.", + "archive_filter_toggle": "Olympiade-Archiv", + "archive_year_label": "Jahrgang", + "archive_grade_label": "Klassenstufe", + "archive_all_groups": "Alle Stufen" } diff --git a/src/locales/el.json b/src/locales/el.json index ef1d4335..c046aff9 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Ο κωδικός ενημερώθηκε. Μπορείτε να συνδεθείτε τώρα.", "reset_password_invalid": "Αυτός ο σύνδεσμος επαναφοράς είναι μη έγκυρος ή έχει λήξει. Ζητήστε νέο.", "reset_password_request_new": "Ζητήστε νέο σύνδεσμο", - "reset_password_mismatch": "Οι δύο κωδικοί δεν ταιριάζουν." + "reset_password_mismatch": "Οι δύο κωδικοί δεν ταιριάζουν.", + "archive_filter_toggle": "Αρχείο Ολυμπιάδων", + "archive_year_label": "Έτος διεξαγωγής", + "archive_grade_label": "Ομάδα τάξεων", + "archive_all_groups": "Όλες οι ομάδες" } diff --git a/src/locales/en.json b/src/locales/en.json index f2241f0d..0a845723 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Password updated. You can log in with it now.", "reset_password_invalid": "This reset link is invalid or has expired. Request a new one.", "reset_password_request_new": "Request a new link", - "reset_password_mismatch": "The two passwords do not match." + "reset_password_mismatch": "The two passwords do not match.", + "archive_filter_toggle": "Olympiad archive", + "archive_year_label": "Edition year", + "archive_grade_label": "Grade group", + "archive_all_groups": "All groups" } diff --git a/src/locales/es.json b/src/locales/es.json index 99bc717f..8fe10b83 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Contraseña actualizada. Ya puedes iniciar sesión.", "reset_password_invalid": "Este enlace de restablecimiento no es válido o ha caducado. Solicita uno nuevo.", "reset_password_request_new": "Solicitar un enlace nuevo", - "reset_password_mismatch": "Las dos contraseñas no coinciden." + "reset_password_mismatch": "Las dos contraseñas no coinciden.", + "archive_filter_toggle": "Archivo de olimpiadas", + "archive_year_label": "Año de la edición", + "archive_grade_label": "Grupo de grado", + "archive_all_groups": "Todos los grupos" } diff --git a/src/locales/fa.json b/src/locales/fa.json index 16621891..b8dfc691 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1178,5 +1178,9 @@ "reset_password_done": "رمز عبور به‌روزرسانی شد. اکنون می‌توانید وارد شوید.", "reset_password_invalid": "این پیوند بازنشانی نامعتبر است یا منقضی شده است. پیوند جدید بگیرید.", "reset_password_request_new": "درخواست پیوند جدید", - "reset_password_mismatch": "دو رمز عبور با هم مطابقت ندارند." + "reset_password_mismatch": "دو رمز عبور با هم مطابقت ندارند.", + "archive_filter_toggle": "آرشیو المپیاد", + "archive_year_label": "سال برگزاری", + "archive_grade_label": "گروه پایه", + "archive_all_groups": "همه گروه‌ها" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 71c9b5b3..783bde39 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Mot de passe mis à jour. Vous pouvez vous connecter.", "reset_password_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.", "reset_password_request_new": "Demander un nouveau lien", - "reset_password_mismatch": "Les deux mots de passe ne correspondent pas." + "reset_password_mismatch": "Les deux mots de passe ne correspondent pas.", + "archive_filter_toggle": "Archive des olympiades", + "archive_year_label": "Année de l'édition", + "archive_grade_label": "Groupe de niveau", + "archive_all_groups": "Tous les groupes" } diff --git a/src/locales/hi.json b/src/locales/hi.json index 8fecef1d..be3bb9a9 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1178,5 +1178,9 @@ "reset_password_done": "पासवर्ड अपडेट हो गया। अब आप लॉगिन कर सकते हैं।", "reset_password_invalid": "यह रीसेट लिंक अमान्य है या समाप्त हो गया है। नया लिंक का अनुरोध करें।", "reset_password_request_new": "नया लिंक का अनुरोध करें", - "reset_password_mismatch": "दोनों पासवर्ड मेल नहीं खाते।" + "reset_password_mismatch": "दोनों पासवर्ड मेल नहीं खाते।", + "archive_filter_toggle": "ओलंपियाड संग्रह", + "archive_year_label": "संस्करण वर्ष", + "archive_grade_label": "कक्षा समूह", + "archive_all_groups": "सभी समूह" } diff --git a/src/locales/hr.json b/src/locales/hr.json index f741f86e..aad5eecc 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Lozinka je ažurirana. Sada se možete prijaviti.", "reset_password_invalid": "Ova poveznica za resetiranje nevažeća je ili je istekla. Zatražite novu.", "reset_password_request_new": "Zatraži novu poveznicu", - "reset_password_mismatch": "Dvije lozinke se ne podudaraju." + "reset_password_mismatch": "Dvije lozinke se ne podudaraju.", + "archive_filter_toggle": "Arhiva olimpijada", + "archive_year_label": "Godina izdanja", + "archive_grade_label": "Skupina razreda", + "archive_all_groups": "Sve skupine" } diff --git a/src/locales/hu.json b/src/locales/hu.json index 0cb2a712..53d04acf 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1178,5 +1178,9 @@ "reset_password_done": "A jelszó frissítve. Most már bejelentkezhetsz.", "reset_password_invalid": "Ez a visszaállító link érvénytelen vagy lejárt. Kérj újat.", "reset_password_request_new": "Új link kérése", - "reset_password_mismatch": "A két jelszó nem egyezik." + "reset_password_mismatch": "A két jelszó nem egyezik.", + "archive_filter_toggle": "Olimpiai archívum", + "archive_year_label": "Kiadás éve", + "archive_grade_label": "Évfolyamcsoport", + "archive_all_groups": "Minden csoport" } diff --git a/src/locales/id.json b/src/locales/id.json index 92c2eb3a..3db598a3 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Kata sandi diperbarui. Anda bisa login sekarang.", "reset_password_invalid": "Tautan pengaturan ulang ini tidak valid atau telah kedaluwarsa. Minta yang baru.", "reset_password_request_new": "Minta tautan baru", - "reset_password_mismatch": "Dua kata sandi tidak cocok." + "reset_password_mismatch": "Dua kata sandi tidak cocok.", + "archive_filter_toggle": "Arsip olimpiade", + "archive_year_label": "Tahun penyelenggaraan", + "archive_grade_label": "Kelompok kelas", + "archive_all_groups": "Semua kelompok" } diff --git a/src/locales/it.json b/src/locales/it.json index 45157468..9ca65d0c 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Password aggiornata. Ora puoi accedere.", "reset_password_invalid": "Questo link di reimpostazione non è valido o è scaduto. Richiedine uno nuovo.", "reset_password_request_new": "Richiedi un nuovo link", - "reset_password_mismatch": "Le due password non coincidono." + "reset_password_mismatch": "Le due password non coincidono.", + "archive_filter_toggle": "Archivio delle olimpiadi", + "archive_year_label": "Anno dell'edizione", + "archive_grade_label": "Gruppo di classe", + "archive_all_groups": "Tutti i gruppi" } diff --git a/src/locales/ja.json b/src/locales/ja.json index ea15b345..206ffb62 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1178,5 +1178,9 @@ "reset_password_done": "パスワードを更新しました。今すぐログインできます。", "reset_password_invalid": "このリセット用リンクは無効か期限切れです。新しいリンクを請求してください。", "reset_password_request_new": "新しいリンクを請求", - "reset_password_mismatch": "2つのパスワードが一致しません。" + "reset_password_mismatch": "2つのパスワードが一致しません。", + "archive_filter_toggle": "オリンピアードアーカイブ", + "archive_year_label": "開催年", + "archive_grade_label": "学年グループ", + "archive_all_groups": "すべてのグループ" } diff --git a/src/locales/kk.json b/src/locales/kk.json index 36f7a994..26ab397d 100644 --- a/src/locales/kk.json +++ b/src/locales/kk.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Құпиясөз жаңартылды. Енді кіре аласыз.", "reset_password_invalid": "Бұл қалпына келтіру сілтемесі жарамсыз немесе мерзімі өтіп кеткен. Жаңасын сұраңыз.", "reset_password_request_new": "Жаңа сілтеме сұрау", - "reset_password_mismatch": "Екі құпиясөз сәйкес емес." + "reset_password_mismatch": "Екі құпиясөз сәйкес емес.", + "archive_filter_toggle": "Олимпиада мұрағаты", + "archive_year_label": "Өткізілген жылы", + "archive_grade_label": "Сынып тобы", + "archive_all_groups": "Барлық топтар" } diff --git a/src/locales/ko.json b/src/locales/ko.json index 193ffa92..fdbebc43 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -1178,5 +1178,9 @@ "reset_password_done": "비밀번호가 업데이트되었습니다. 지금 로그인할 수 있습니다.", "reset_password_invalid": "이 재설정 링크는 유효하지 않거나 만료되었습니다. 새 링크를 요청하세요.", "reset_password_request_new": "새 링크 요청", - "reset_password_mismatch": "두 비밀번호가 일치하지 않습니다." + "reset_password_mismatch": "두 비밀번호가 일치하지 않습니다.", + "archive_filter_toggle": "올림피아드 아카이브", + "archive_year_label": "개최 연도", + "archive_grade_label": "학년 그룹", + "archive_all_groups": "모든 그룹" } diff --git a/src/locales/pl.json b/src/locales/pl.json index 865b5f54..2c6c7dda 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Hasło zostało zaktualizowane. Możesz się teraz zalogować.", "reset_password_invalid": "Ten link do resetowania jest nieprawidłowy lub wygasł. Poproś o nowy.", "reset_password_request_new": "Poproś o nowy link", - "reset_password_mismatch": "Hasła nie są identyczne." + "reset_password_mismatch": "Hasła nie są identyczne.", + "archive_filter_toggle": "Archiwum olimpiad", + "archive_year_label": "Rocznik", + "archive_grade_label": "Grupa klas", + "archive_all_groups": "Wszystkie grupy" } diff --git a/src/locales/pt.json b/src/locales/pt.json index 13efb431..fd862e9b 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Senha atualizada. Você já pode entrar.", "reset_password_invalid": "Este link de redefinição é inválido ou expirou. Solicite um novo.", "reset_password_request_new": "Solicitar novo link", - "reset_password_mismatch": "As duas senhas não coincidem." + "reset_password_mismatch": "As duas senhas não coincidem.", + "archive_filter_toggle": "Arquivo de olimpíadas", + "archive_year_label": "Ano da edição", + "archive_grade_label": "Grupo de série", + "archive_all_groups": "Todos os grupos" } diff --git a/src/locales/ro.json b/src/locales/ro.json index 44db99fc..1c46f630 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Parola a fost actualizată. Te poți autentifica acum.", "reset_password_invalid": "Acest link de resetare este invalid sau a expirat. Cere unul nou.", "reset_password_request_new": "Cere un link nou", - "reset_password_mismatch": "Cele două parole nu coincid." + "reset_password_mismatch": "Cele două parole nu coincid.", + "archive_filter_toggle": "Arhiva olimpiadelor", + "archive_year_label": "Anul ediției", + "archive_grade_label": "Grupa de clase", + "archive_all_groups": "Toate grupele" } diff --git a/src/locales/ru.json b/src/locales/ru.json index 6bbd635a..95634413 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Пароль обновлён. Теперь вы можете войти.", "reset_password_invalid": "Эта ссылка для сброса недействительна или истекла. Запросите новую.", "reset_password_request_new": "Запросить новую ссылку", - "reset_password_mismatch": "Пароли не совпадают." + "reset_password_mismatch": "Пароли не совпадают.", + "archive_filter_toggle": "Архив олимпиад", + "archive_year_label": "Год проведения", + "archive_grade_label": "Группа классов", + "archive_all_groups": "Все группы" } diff --git a/src/locales/sr-Latn.json b/src/locales/sr-Latn.json index ea670ec5..3e47d84a 100644 --- a/src/locales/sr-Latn.json +++ b/src/locales/sr-Latn.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Lozinka je ažurirana. Sada se možete prijaviti.", "reset_password_invalid": "Ovaj link za resetovanje je nevažeći ili je istekao. Zatražite novi.", "reset_password_request_new": "Zatraži novi link", - "reset_password_mismatch": "Dve lozinke se ne poklapaju." + "reset_password_mismatch": "Dve lozinke se ne poklapaju.", + "archive_filter_toggle": "Arhiva olimpijada", + "archive_year_label": "Godina izdanja", + "archive_grade_label": "Grupa razreda", + "archive_all_groups": "Sve grupe" } diff --git a/src/locales/th.json b/src/locales/th.json index a594b3de..2043327b 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -1178,5 +1178,9 @@ "reset_password_done": "อัปเดตรหัสผ่านแล้ว คุณเข้าสู่ระบบได้เลย", "reset_password_invalid": "ลิงก์รีเซ็ตนี้ไม่ถูกต้องหรือหมดอายุแล้ว ขอลิงก์ใหม่ได้เลย", "reset_password_request_new": "ขอลิงก์ใหม่", - "reset_password_mismatch": "รหัสผ่านทั้งสองไม่ตรงกัน" + "reset_password_mismatch": "รหัสผ่านทั้งสองไม่ตรงกัน", + "archive_filter_toggle": "คลังข้อสอบโอลิมปิก", + "archive_year_label": "ปีที่จัด", + "archive_grade_label": "กลุ่มชั้นเรียน", + "archive_all_groups": "ทุกกลุ่ม" } diff --git a/src/locales/tr.json b/src/locales/tr.json index d9114cde..e2bd6a43 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Şifre güncellendi. Artık giriş yapabilirsin.", "reset_password_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi dolmuş. Yeni bir tane iste.", "reset_password_request_new": "Yeni bağlantı iste", - "reset_password_mismatch": "İki şifre birbiriyle uyuşmuyor." + "reset_password_mismatch": "İki şifre birbiriyle uyuşmuyor.", + "archive_filter_toggle": "Olimpiyat arşivi", + "archive_year_label": "Yıl", + "archive_grade_label": "Sınıf grubu", + "archive_all_groups": "Tüm gruplar" } diff --git a/src/locales/uk.json b/src/locales/uk.json index 7ec60246..a8d39e65 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Пароль оновлено. Тепер ви можете увійти.", "reset_password_invalid": "Це посилання для скидання недійсне або застаріле. Запросіть нове.", "reset_password_request_new": "Запросити нове посилання", - "reset_password_mismatch": "Паролі не співпадають." + "reset_password_mismatch": "Паролі не співпадають.", + "archive_filter_toggle": "Архів олімпіад", + "archive_year_label": "Рік проведення", + "archive_grade_label": "Група класів", + "archive_all_groups": "Усі групи" } diff --git a/src/locales/vi.json b/src/locales/vi.json index 3cfcdb7a..63d5b66b 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1178,5 +1178,9 @@ "reset_password_done": "Đã cập nhật mật khẩu. Bạn có thể đăng nhập ngay bây giờ.", "reset_password_invalid": "Liên kết đặt lại này không hợp lệ hoặc đã hết hạn. Hãy yêu cầu liên kết mới.", "reset_password_request_new": "Yêu cầu liên kết mới", - "reset_password_mismatch": "Hai mật khẩu không khớp." + "reset_password_mismatch": "Hai mật khẩu không khớp.", + "archive_filter_toggle": "Kho đề Olympic", + "archive_year_label": "Năm tổ chức", + "archive_grade_label": "Nhóm lớp", + "archive_all_groups": "Tất cả các nhóm" } diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 5e3fa41f..12c38778 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -1178,5 +1178,9 @@ "reset_password_done": "密码已更新,现在即可登录。", "reset_password_invalid": "该重置链接无效或已过期,请重新申请。", "reset_password_request_new": "申请新链接", - "reset_password_mismatch": "两次输入的密码不一致。" + "reset_password_mismatch": "两次输入的密码不一致。", + "archive_filter_toggle": "奥赛题库", + "archive_year_label": "届次年份", + "archive_grade_label": "年级组", + "archive_all_groups": "全部组别" } diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index 3c7f3e31..ff6a6158 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -1178,5 +1178,9 @@ "reset_password_done": "密碼已更新,現在即可登入。", "reset_password_invalid": "此重設連結無效或已過期,請重新申請。", "reset_password_request_new": "申請新連結", - "reset_password_mismatch": "兩次輸入的密碼不一致。" + "reset_password_mismatch": "兩次輸入的密碼不一致。", + "archive_filter_toggle": "奧賽題庫", + "archive_year_label": "屆次年份", + "archive_grade_label": "年級組", + "archive_all_groups": "全部組別" } diff --git a/src/types/index.ts b/src/types/index.ts index da527b86..46f4e876 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,6 +67,11 @@ export interface ProblemType { // Wave E: partial-code problems — full skeleton + locked regions. code_template?: string; code_template_language?: string; + // Olympiad-archive provenance (roadmap RO-6/CNT-2): where the problem + // came from — the archive browse axis + attribution chips. + source_contest?: string; + source_year?: number | null; + grade_group?: string; // Community-translation attribution (i18n wave, owner rule (c)): the // "translated by @user" credits, sourced from approved proposals. translations?: TranslationAttribution[]; From 7d032d8704c52f92386a74f5c06640ca853ddb23 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:46:17 +0200 Subject: [PATCH 2/3] =?UTF-8?q?security:=20serialization=20boundary=20for?= =?UTF-8?q?=20tools=20diagnostics=20=E2=80=94=20sandbox=20stderr/compiler?= =?UTF-8?q?=20output=20now=20answers=20from=20the=20persisted=20StressRun?= =?UTF-8?q?=20row=20(CodeQL=20py/stack-trace-exposure,=204=20medium=20aler?= =?UTF-8?q?ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/problems/tooling.py | 57 +++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/backend/problems/tooling.py b/backend/problems/tooling.py index 0992b71e..47be3f6e 100644 --- a/backend/problems/tooling.py +++ b/backend/problems/tooling.py @@ -416,20 +416,25 @@ def _run_full(executable_path, language: str, stdin_data: str, workspace_dir): def _compile_or_400(code, language, workspace_dir, key, request): """Compile one program for a tools run. - Returns (ok, executable, compiler_output); on failure the audit row is - persisted and the caller answers 400 with the compiler text. + Returns (ok, executable, persisted_detail); on failure the audit row is + persisted AND read back from the database before it reaches the API + response — the same serialization boundary StressTestView uses, so + sandbox diagnostics (compiler stderr, tracebacks) cross a round-trip + instead of flowing straight into a Response. """ ok, compiler_output, executable = compile_code_in_sandbox( code, language, workspace_dir, None ) if not ok: - StressRun.objects.create( + record = StressRun.objects.create( user=request.user, iterations=0, found=False, detail={f"{key}_code": compiler_output[:2000]}, ) - return ok, executable, compiler_output + persisted = StressRun.objects.get(pk=record.pk) + return False, None, persisted.detail + return ok, executable, None class ValidateRequestSerializer(serializers.Serializer): @@ -486,13 +491,12 @@ def post(self, request): ("generator", "generator_language", "generator_code"), ("validator", "validator_language", "validator_code"), ): - ok, executable, compiler_output = _compile_or_400( + ok, executable, persisted_detail = _compile_or_400( data[code_key], data[lang_key], workspace_dir, key, request ) if not ok: return Response( - {f"{key}_code": compiler_output[:2000]}, - status=status.HTTP_400_BAD_REQUEST, + persisted_detail, status=status.HTTP_400_BAD_REQUEST ) if key == "generator": gen_exec, gen_lang = executable, data[lang_key] @@ -530,12 +534,16 @@ def post(self, request): found=violation is not None, detail={"violation": violation} if violation else {}, ) + # Serialization boundary: the violation (with the validator's + # sandbox stderr) is re-read from the audit row, never answered + # straight from the sandbox flow. + persisted = StressRun.objects.get(pk=record.pk) return Response( { - "id": record.id, - "iterations": record.iterations, - "valid": violation is None, - "violation": violation, + "id": persisted.id, + "iterations": persisted.iterations, + "valid": not persisted.found, + "violation": persisted.detail.get("violation"), } ) @@ -604,7 +612,7 @@ def post(self, request): results: list[dict] = [] with _judge_workspace("genout_") as workspace: workspace_dir = pathlib.Path(workspace) - ok, ref_exec, ref_log = _compile_or_400( + ok, ref_exec, ref_detail = _compile_or_400( data["reference_code"], data["reference_language"], workspace_dir, @@ -612,14 +620,11 @@ def post(self, request): request, ) if not ok: - return Response( - {"reference_code": ref_log[:2000]}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(ref_detail, status=status.HTTP_400_BAD_REQUEST) gen_exec = None gen_lang = None if not inputs: - ok, gen_exec, gen_log = _compile_or_400( + ok, gen_exec, gen_detail = _compile_or_400( data["generator_code"], data["generator_language"], workspace_dir, @@ -627,10 +632,7 @@ def post(self, request): request, ) if not ok: - return Response( - {"generator_code": gen_log[:2000]}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(gen_detail, status=status.HTTP_400_BAD_REQUEST) gen_lang = data["generator_language"] for _ in range(data["count"]): gen_verdict, gen_out, _err = _run_full( @@ -657,13 +659,18 @@ def post(self, request): user=request.user, iterations=len(results), found=False, - detail={"generated_outputs": len(results)}, + detail={"results": results}, ) + # Serialization boundary again: results carry the reference + # program's sandbox stderr on non-AC rows — re-read from the audit + # row before answering (CodeQL taint break, #238 pattern). + persisted = StressRun.objects.get(pk=record.pk) + persisted_results = persisted.detail["results"] return Response( { - "id": record.id, - "count": len(results), - "results": results, + "id": persisted.id, + "count": len(persisted_results), + "results": persisted_results, } ) From a5ea012734fe7766840a4ea030a34c755b8cf0f2 Mon Sep 17 00:00:00 2001 From: Andrei Mihai Visalon <54636077+Prekzursil@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:48:22 +0200 Subject: [PATCH 3/3] =?UTF-8?q?security:=20sandbox=20diagnostics=20never?= =?UTF-8?q?=20cross=20into=20tool=20responses=20=E2=80=94=20verdicts=20+?= =?UTF-8?q?=20program=20output=20only;=20stderr/compiler=20text=20persists?= =?UTF-8?q?=20on=20the=20StressRun=20audit=20row=20(clears=20the=20CodeQL?= =?UTF-8?q?=20stack-trace-exposure=20block)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/problems/test_tooling_closure.py | 38 +++++++++++++---- backend/problems/tooling.py | 53 ++++++++++++++---------- 2 files changed, 61 insertions(+), 30 deletions(-) diff --git a/backend/problems/test_tooling_closure.py b/backend/problems/test_tooling_closure.py index 3ef9092e..da0be58c 100644 --- a/backend/problems/test_tooling_closure.py +++ b/backend/problems/test_tooling_closure.py @@ -124,7 +124,13 @@ def test_first_violation_surfaced(self, compile_mock, run_mock): self.assertFalse(response.data["valid"]) self.assertEqual(response.data["violation"]["phase"], "validator") self.assertEqual(response.data["violation"]["input"], "42\n") - self.assertEqual(response.data["violation"]["message"], "n too big") + # The validator's stderr never crosses into the response (CodeQL + # stack-trace-exposure) — it lands on the audit row instead. + self.assertNotIn("message", response.data["violation"]) + from scratch.models import StressRun + + audit = StressRun.objects.filter(user=self.verifier).order_by("-id").first() + self.assertEqual(audit.detail["message"], "n too big") @mock.patch("problems.tooling.run_code_in_sandbox") @mock.patch("problems.tooling.compile_code_in_sandbox") @@ -139,15 +145,20 @@ def test_generator_crash(self, compile_mock, run_mock): self.assertEqual(response.data["violation"]["phase"], "generator") @mock.patch("problems.tooling.compile_code_in_sandbox") - def test_compile_failure_returns_compiler_text(self, compile_mock): + def test_compile_failure_returns_static_hint_and_audits(self, compile_mock): compile_mock.return_value = (False, "SyntaxError: bad", None) self.client.force_authenticate(self.creator) response = self.client.post( "/api/v1/problems/tools/validate/", self.payload(), format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - # The generator compiles first, so its diagnostics come back first. - self.assertIn("SyntaxError", str(response.data["generator_code"])) + # The generator compiles first; the response is a STATIC hint (no + # sandbox diagnostics cross the API), the real output is audited. + self.assertIn("audit record", str(response.data["generator_code"])) + from scratch.models import StressRun + + audit = StressRun.objects.filter(user=self.creator).order_by("-id").first() + self.assertIn("SyntaxError", audit.detail["generator_code"]) # --------------------------------------------------------------------------- @@ -219,7 +230,12 @@ def test_non_ac_reference_reported_per_input(self, compile_mock, run_mock): ) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data["results"][0]["verdict"], "TLE") - self.assertEqual(response.data["results"][0]["message"], "slow") + self.assertNotIn("message", response.data["results"][0]) + from scratch.models import StressRun + + audit = StressRun.objects.filter(user=self.verifier).order_by("-id").first() + # Both explicit inputs ran non-AC, so both stderr excerpts persist. + self.assertEqual(audit.detail["diagnostics"], ["slow", "slow"]) @mock.patch("problems.tooling.run_code_in_sandbox") @mock.patch("problems.tooling.compile_code_in_sandbox") @@ -273,7 +289,11 @@ def test_reference_compile_failure(self, compile_mock): "/api/v1/problems/tools/generate-outputs/", self.payload(), format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("bad ref", str(response.data["reference_code"])) + self.assertIn("audit record", str(response.data["reference_code"])) + from scratch.models import StressRun as _SR + + audit = _SR.objects.filter(user=self.creator).order_by("-id").first() + self.assertIn("bad ref", audit.detail["reference_code"]) @mock.patch("problems.tooling.run_code_in_sandbox") @mock.patch("problems.tooling.compile_code_in_sandbox") @@ -294,7 +314,11 @@ def test_generator_compile_failure(self, compile_mock, run_mock): "/api/v1/problems/tools/generate-outputs/", payload, format="json" ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("bad gen", str(response.data["generator_code"])) + self.assertIn("audit record", str(response.data["generator_code"])) + from scratch.models import StressRun as _SR + + audit = _SR.objects.filter(user=self.creator).order_by("-id").first() + self.assertIn("bad gen", audit.detail["generator_code"]) # --------------------------------------------------------------------------- diff --git a/backend/problems/tooling.py b/backend/problems/tooling.py index 47be3f6e..0cf7d0d2 100644 --- a/backend/problems/tooling.py +++ b/backend/problems/tooling.py @@ -51,6 +51,14 @@ STRESS_TIME_LIMIT_MS = 5000 STRESS_MEMORY_LIMIT_KB = 262144 +#: Static compile-failure hint — the response NEVER carries sandbox or +#: compiler diagnostics (exception-derived text); they live on the +#: persisted StressRun audit row instead. +_COMPILE_FAILED_HINT = ( + "Compilation failed — diagnostics are on the run's audit record; " + "use the scratch console to reproduce them." +) + def _run_compiled( executable_path, language: str, stdin_data: str, workspace_dir @@ -426,14 +434,13 @@ def _compile_or_400(code, language, workspace_dir, key, request): code, language, workspace_dir, None ) if not ok: - record = StressRun.objects.create( + StressRun.objects.create( user=request.user, iterations=0, found=False, detail={f"{key}_code": compiler_output[:2000]}, ) - persisted = StressRun.objects.get(pk=record.pk) - return False, None, persisted.detail + return False, None, {f"{key}_code": _COMPILE_FAILED_HINT} return ok, executable, None @@ -482,6 +489,7 @@ def post(self, request): ) violation = None + detail_message = "" runs = 0 with _judge_workspace("validate_") as workspace: workspace_dir = pathlib.Path(workspace) @@ -524,26 +532,27 @@ def post(self, request): "phase": "validator", "input": gen_out[:4000], "verdict": val_verdict, - "message": val_err[:1000], } + detail_message = val_err[:1000] break record = StressRun.objects.create( user=request.user, iterations=runs, found=violation is not None, - detail={"violation": violation} if violation else {}, + detail={"violation": violation, "message": detail_message} + if violation + else {}, ) - # Serialization boundary: the violation (with the validator's - # sandbox stderr) is re-read from the audit row, never answered - # straight from the sandbox flow. - persisted = StressRun.objects.get(pk=record.pk) + # The response carries only verdicts + the offending input; the + # validator's stderr stays on the audit row (no tainted value + # crosses into the Response — CodeQL stack-trace-exposure). return Response( { - "id": persisted.id, - "iterations": persisted.iterations, - "valid": not persisted.found, - "violation": persisted.detail.get("violation"), + "id": record.id, + "iterations": record.iterations, + "valid": violation is None, + "violation": violation, } ) @@ -645,32 +654,30 @@ def post(self, request): ) inputs.append(gen_out) + diagnostics: list[str] = [] for stdin_data in inputs: verdict, out, err = _run_full( ref_exec, data["reference_language"], stdin_data, workspace_dir ) + # Only verdicts + program output cross into the response; + # non-AC stderr (exception-derived) goes to the audit row. entry = {"input": stdin_data[:20000], "output": out[:20000]} if verdict != "AC": entry["verdict"] = verdict - entry["message"] = err[:1000] + diagnostics.append(err[:1000]) results.append(entry) record = StressRun.objects.create( user=request.user, iterations=len(results), found=False, - detail={"results": results}, + detail={"results": results, "diagnostics": diagnostics}, ) - # Serialization boundary again: results carry the reference - # program's sandbox stderr on non-AC rows — re-read from the audit - # row before answering (CodeQL taint break, #238 pattern). - persisted = StressRun.objects.get(pk=record.pk) - persisted_results = persisted.detail["results"] return Response( { - "id": persisted.id, - "count": len(persisted_results), - "results": persisted_results, + "id": record.id, + "count": len(results), + "results": results, } )