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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions courts/case_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------------------

Expand Down
21 changes: 21 additions & 0 deletions courts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from materials.jsonld import court_case_material_iri

from . import case_status as cs
from .models import (
BlacklistedFirm,
CaseEntity,
Expand All @@ -27,6 +28,8 @@ class CourtCaseSerializer(serializers.ModelSerializer):
# The court-case row's synthesized @id IRI (/courtcase/<court>/<case_number>),
# 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
Expand All @@ -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")
Expand Down
79 changes: 79 additions & 0 deletions courts/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down
Loading