diff --git a/courts/case_status.py b/courts/case_status.py index 9d012749..ef989db4 100644 --- a/courts/case_status.py +++ b/courts/case_status.py @@ -56,6 +56,25 @@ REVERSED = "REVERSED" PARTIALLY_REVERSED = "PARTIALLY_REVERSED" +# The closed set of values ``CourtCase.verdict_type`` is *supposed* to hold. The +# column is a plain CharField with no DB constraint, and historic enrichment +# wrote raw portal text straight into it — measured 2026-08-06 on prod, 1,323 +# Supreme rows (1.27% of the populated ones, 37 distinct values) hold Nepali +# strings rather than a member here, including bench referrals +# (``पूर्ण इजलासमा पेस हुने``), interlocutory orders (``कैफियत प्रतिवेदन माग्ने``) +# and pure punctuation (``।।।।।।।``). Every such row has a NULL verdict date, so +# they are enrichment noise rather than asserted verdicts — but a referral +# rendered as an outcome would tell a reader a live case was decided. Anything +# crossing a public boundary must therefore be filtered through this set; see +# ``courts.serializers.CourtCaseSerializer.get_verdict_type``. +VERDICT_TYPES = frozenset({ + CONVICTED, ACQUITTED, PARTIALLY_CONVICTED, + CLAIM_UPHELD, CLAIM_DENIED, PARTIALLY_UPHELD, + SETTLED, WITHDRAWN, DISMISSED, QUASHED, + PROCEDURAL, ABEYANCE, STRUCK_OFF, AMENDED, OTHER, + AFFIRMED, REVERSED, PARTIALLY_REVERSED, +}) + # --- vocabulary -------------------------------------------------------------- diff --git a/courts/serializers.py b/courts/serializers.py index 0ea5017b..a341fca5 100644 --- a/courts/serializers.py +++ b/courts/serializers.py @@ -3,6 +3,7 @@ from materials.jsonld import court_case_material_iri +from . import case_status as cs from .models import ( BlacklistedFirm, CaseEntity, @@ -27,6 +28,8 @@ class CourtCaseSerializer(serializers.ModelSerializer): # The court-case row's synthesized @id IRI (/courtcase//), # distinct from the material IRI above. Derived from the composite key. courtcase_iri = serializers.CharField(source="iri", read_only=True) + # Whitelisted against cs.VERDICT_TYPES — see get_verdict_type below. + verdict_type = serializers.SerializerMethodField() class Meta: model = CourtCase @@ -35,11 +38,29 @@ class Meta: "registration_date_ad", "case_type", "case_status", "plaintiff", "defendant", "nes_id", "document_sources", "material_id", "courtcase_iri", + "verdict_type", "verdict_date_bs", "verdict_date_ad", ] def get_material_id(self, obj: CourtCase) -> str: return court_case_material_iri(obj.court_id, obj.case_number) + def get_verdict_type(self, obj: CourtCase) -> str | None: + """Expose ``verdict_type`` only when it is a real enum member. + + The column carries no DB constraint and historic Supreme enrichment + wrote raw portal text into it (see ``cs.VERDICT_TYPES``). Publishing + that verbatim would state an outcome the court never reached — a bench + referral such as ``पूर्ण इजलासमा पेस हुने`` ("to be presented to the full + bench") reads like a disposition but means the case is still live. + + Unrecognised values become ``None`` rather than leaking: the honest + public claim is "we hold no classified verdict for this docket", which + is what a null says. The raw value stays in the database for the DQ + backfill to repair. + """ + value = (obj.verdict_type or "").strip() + return value if value in cs.VERDICT_TYPES else None + class CourtCaseHearingSerializer(serializers.ModelSerializer): court_identifier = serializers.CharField(source="court_id") diff --git a/courts/tests/test_api.py b/courts/tests/test_api.py index 84717423..4fcf6565 100644 --- a/courts/tests/test_api.py +++ b/courts/tests/test_api.py @@ -22,6 +22,7 @@ from rest_framework import status from rest_framework.test import APITestCase +from courts import case_status as cs from courts.models import ( BlacklistedFirm, CaseEntity, @@ -147,6 +148,84 @@ def test_entities_search(self): self.assertEqual(len(resp.data["results"]), 1) +class VerdictExposureTests(_DbAPITestCase): + """``verdict_type`` crosses the public boundary through a whitelist. + + The column has no DB constraint and historic Supreme enrichment wrote raw + portal text into it (measured 2026-08-06: 1,323 prod rows, 37 distinct + values). The dangerous class is not the punctuation garbage but the bench + referrals and interlocutory orders, which read as dispositions while the + case is still live — so the serializer must publish only real enum members. + """ + + @classmethod + def setUpTestData(cls): + cls.court = Court.objects.create( + identifier="supreme", + court_type="supreme", + full_name_nepali="सर्वोच्च अदालत", + full_name_english="Supreme Court", + ) + # A promoted appellate outcome — the shape the case stepper reads. + CourtCase.objects.create( + case_number="081-CR-1038", + court=cls.court, + verdict_type="AFFIRMED", + verdict_date_bs="2082-03-20", + verdict_date_ad=date(2025, 7, 4), + ) + # A bench referral stored raw in verdict_type. Reads like a decision + # ("to be presented to the full bench"); the case is still live, and + # every such prod row carries a NULL verdict date exactly as here. + CourtCase.objects.create( + case_number="076-RB-0582", + court=cls.court, + verdict_type="पूर्ण इजलासमा पेस हुने", + ) + # Scrape garbage, likewise seen in prod. + CourtCase.objects.create( + case_number="067-WO-0527", court=cls.court, verdict_type="।।।।।।।", + ) + # Never enriched at all. + CourtCase.objects.create(case_number="081-CR-1318", court=cls.court) + + def _get(self, case_number): + resp = self.client.get(f"/api/courtcases/supreme/{case_number}") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + return resp.data + + def test_enum_member_is_published_with_its_dates(self): + data = self._get("081-CR-1038") + self.assertEqual(data["verdict_type"], "AFFIRMED") + self.assertEqual(data["verdict_date_bs"], "2082-03-20") + self.assertEqual(str(data["verdict_date_ad"]), "2025-07-04") + + def test_bench_referral_is_not_published_as_a_verdict(self): + data = self._get("076-RB-0582") + self.assertIsNone(data["verdict_type"]) + self.assertIsNone(data["verdict_date_bs"]) + + def test_scrape_garbage_is_not_published(self): + self.assertIsNone(self._get("067-WO-0527")["verdict_type"]) + + def test_unenriched_docket_reports_null(self): + self.assertIsNone(self._get("081-CR-1318")["verdict_type"]) + + def test_whitelist_covers_every_declared_constant(self): + """Guards against a new verdict constant being added and silently + dropped on the wire because nobody updated ``VERDICT_TYPES``.""" + declared = { + value + for name, value in vars(cs).items() + if name.isupper() + and not name.startswith("_") + and isinstance(value, str) + and value == name + and name not in {"PENDING", "DECIDED", "UNKNOWN"} # lifecycle, not verdict + } + self.assertEqual(declared, set(cs.VERDICT_TYPES)) + + class SearchRetiredTests(_DbAPITestCase): """The NGM 501 search stub was retired in the unified-search cutover.