From c8120a1429f014beb7f35cd6fd14741e6218c5a8 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 21:22:08 -0400 Subject: [PATCH 1/7] Reuse search index metadata across court filing choices --- efile_app/efile/api/filing_code_search.py | 2 +- .../efile/services/filing_code_search.py | 21 ++++++++---- .../efile/tests/test_filing_code_search.py | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/efile_app/efile/api/filing_code_search.py b/efile_app/efile/api/filing_code_search.py index 919daa94..29655b99 100644 --- a/efile_app/efile/api/filing_code_search.py +++ b/efile_app/efile/api/filing_code_search.py @@ -52,7 +52,7 @@ def filing_code_search(request): path_id = int(request.GET["path_id"]) except ValueError: return JsonResponse({"error": "Invalid filing path."}, status=400) - path = index.paths.filter(pk=path_id, initial=initial).select_related("index").first() + path = index.paths.filter(pk=path_id, initial=initial).first() if path is None: return JsonResponse({"error": "The code list has changed. Search again."}, status=409) try: diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index d5ca0ddd..b3f72ee7 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -468,11 +468,17 @@ def _refresh_index_legacy(jurisdiction, *, progress=None, cache_dir=None): def current_index(jurisdiction): - return FilingCodeIndex.objects.filter( - jurisdiction=jurisdiction, - source_url=source_url(), - rules_digest=rules()[1], - ).first() + # Search needs the vocabulary, but not the much larger import snapshots. + # Keep snapshots lazy for callers that inspect synchronization metadata. + return ( + FilingCodeIndex.objects.filter( + jurisdiction=jurisdiction, + source_url=source_url(), + rules_digest=rules()[1], + ) + .defer("court_snapshots") + .first() + ) def case_description(path): @@ -648,7 +654,10 @@ def search_paths(index, query, *, initial=True, offset=0, limit=20, names=None, return { "results": [ serialize_path(path, query=query, corrected=corrected, resolved_query=resolved_query) - for path in paths.select_related("index")[offset : offset + limit] + # index.paths already carries its index as a known related object. + # Joining it repeats megabytes of vocabulary/snapshots for every + # row, particularly on the court screen's 500-result pages. + for path in paths[offset : offset + limit] ], "total": total, "corrected_terms": corrected, diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index 7f360d7d..17932e29 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -1187,3 +1187,35 @@ def test_other_stage_count_points_new_case_searches_at_existing_filings(catalog) assert new["other_stage_total"] == existing["total"] assert existing["other_stage_total"] == new["total"] assert search_grouped_paths(index, "eviction", initial=True, offset=20)["other_stage_total"] is None + + +def test_search_reuses_index_metadata_across_results(index, django_assert_num_queries): + # Keep real serialization: it accesses path.index for vocabulary and state + # guidance. Every result must reuse the already loaded index, without a + # join that decodes its potentially multi-megabyte JSON again per row. + from efile.models import FilingCodeIndex + from efile.services.filing_code_search import serialize_path + + def serialize_known_index(path, **kwargs): + assert path.index is index + return serialize_path(path, **kwargs) + + with ( + patch.object(FilingCodeIndex, "from_db", side_effect=AssertionError("Refetched index metadata")), + patch("efile.services.filing_code_search.serialize_path", side_effect=serialize_known_index), + django_assert_num_queries(2), + ): + result = search_paths(index, "eviction", limit=500) + assert result["total"] == 2 + assert {path["court"]["code"] for path in result["results"]} == {"housing", "district"} + + +def test_search_index_leaves_import_snapshots_lazy(index, django_assert_num_queries): + expected_snapshots = index.court_snapshots + with django_assert_num_queries(1): + loaded = current_index("massachusetts") + assert loaded is not None + assert loaded.vocabulary == index.vocabulary + assert loaded.get_deferred_fields() == {"court_snapshots"} + with django_assert_num_queries(1): + assert loaded.court_snapshots == expected_snapshots From 106bde7d775ae7d4175879604321bc6dcf57532a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 21:45:46 -0400 Subject: [PATCH 2/7] Filter court choices using filing destination eligibility --- efile_app/efile/api/dropdown_views.py | 6 +-- efile_app/efile/api/filing_code_search.py | 4 ++ efile_app/efile/services/court_selection.py | 52 ++----------------- .../efile/services/filing_code_search.py | 33 +++++++++--- efile_app/efile/tests/test_court_selection.py | 29 ++++++----- .../efile/tests/test_filing_code_copy.py | 6 +++ .../efile/tests/test_filing_code_search.py | 52 +++++++++++++++++++ .../efile/tests/test_filing_code_sync.py | 6 +++ 8 files changed, 115 insertions(+), 73 deletions(-) diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index d2c25d6b..d9015ea7 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -262,10 +262,8 @@ def get_courts(request): # Make API call to external jurisdiction endpoint api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/" - # `fileable_only=true` is incomplete on the EFSP test service and - # hides courts that do expose valid filing categories. Later - # dropdown calls still validate the chosen court's hierarchy. - params = {"fileable_only": False, "with_names": True} + # Published categories do not prove a location accepts filings. + params = {"fileable_only": True, "with_names": True} try: # Make the API request with auth tokens if available diff --git a/efile_app/efile/api/filing_code_search.py b/efile_app/efile/api/filing_code_search.py index 29655b99..d8ebe432 100644 --- a/efile_app/efile/api/filing_code_search.py +++ b/efile_app/efile/api/filing_code_search.py @@ -133,6 +133,10 @@ def filing_code_search(request): if purpose or document: return JsonResponse({"error": "Use grouped search with filing-type filters."}, status=400) result = search_paths(index, query, initial=initial, offset=offset) + except requests.RequestException: + return JsonResponse( + {"error": "We could not check which courts accept filings. Try again in a moment."}, status=503 + ) except ValueError as error: return JsonResponse({"error": str(error)}, status=409) result.update( diff --git a/efile_app/efile/services/court_selection.py b/efile_app/efile/services/court_selection.py index 007f8f65..e090b9ae 100644 --- a/efile_app/efile/services/court_selection.py +++ b/efile_app/efile/services/court_selection.py @@ -90,20 +90,15 @@ def selector_config(jurisdiction: str) -> dict[str, Any] | None: def fetch_courts(jurisdiction: str) -> list[dict[str, str]]: - """Every court the e-filing service will accept a filing for, as options. + """Courts explicitly accepting initial or subsequent filings.""" - ``fileable_only`` is unreliable on the test service -- it hides courts that - do offer filing categories -- so the whole named list is fetched and the - rows that are plainly not courts are dropped here instead. - """ - - cache_key = f"court-selector:courts:{jurisdiction}" + cache_key = f"court-selector:fileable-courts:{jurisdiction}" cached = cache.get(cache_key) if cached is not None: return cached url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/" - response = requests.get(url, params={"fileable_only": False, "with_names": True}, timeout=15) + response = requests.get(url, params={"fileable_only": True, "with_names": True}, timeout=15) response.raise_for_status() payload = response.json() courts = [ @@ -112,8 +107,6 @@ def fetch_courts(jurisdiction: str) -> list[dict[str, str]]: if isinstance(court, dict) and court.get("code") and court.get("name") if not is_non_filing_court(court["name"]) ] - fileable = _fileable_codes(jurisdiction) - courts = [court for court in courts if not _is_heading_only(court, courts, fileable)] rules = (selector_config(jurisdiction) or {}).get("court_names") or [] for court in courts: court["text"] = _readable_name(court["text"], rules) @@ -143,45 +136,6 @@ def _readable_name(name: str, rules: list[dict[str, str]]) -> str: return name -def _fileable_codes(jurisdiction: str) -> set[str] | None: - """The courts the e-filing service says accept filings, or ``None``. - - Taken as a hint rather than as the truth. It is demonstrably incomplete -- - Cook County's Chancery division is missing from it and offers three case - categories -- so it is only ever used to confirm something already suspected - from the shape of the list, and a request that fails changes nothing. - """ - - try: - response = requests.get( - f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/", - params={"fileable_only": True, "with_names": True}, - timeout=15, - ) - response.raise_for_status() - return {str(court["code"]) for court in response.json() if isinstance(court, dict) and court.get("code")} - except (requests.RequestException, ValueError, KeyError): - logger.warning("Could not read the fileable court list for %s", jurisdiction) - return None - - -def _is_heading_only(court: dict[str, str], courts: list[dict[str, str]], fileable: set[str] | None) -> bool: - """Whether a court is only a heading over the courts underneath it. - - "Cook County" is such a row: every Cook filing goes to one of the eighty - locations whose code hangs off it, and choosing the county itself is a dead - end -- the case category list comes back empty and the filer is stuck with - no way to see why. Both things have to be true to drop it, because a - division like Cook County - Chancery has locations under it and still takes - filings of its own. - """ - - if fileable is None or court["value"] in fileable: - return False - prefix = f"{court['value']}:" - return any(other["value"].startswith(prefix) for other in courts) - - def _fill(template: Any, answers: dict[str, str]) -> Any: """Substitute ``{step_id}`` placeholders with the answers given so far.""" diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index b3f72ee7..aeb24061 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -2,7 +2,7 @@ Snowball stems and YAML concepts are materialized when refreshing the index. PostgreSQL uses an indexed simple tsvector; SQLite uses an FTS5 inverted index. -No external service is called while searching. Selection is revalidated live. +Court eligibility is cached briefly; selection is revalidated live. """ from __future__ import annotations @@ -303,8 +303,9 @@ def get(self, suffix="", **params): result[code] = {"code": code, "name": str(item["name"])} return list(result.values()) - def courts(self): - return [c for c in self.get(fileable_only="false", with_names="true") if not is_non_filing_court(c["name"])] + def courts(self, initial=None): + timing = "INITIAL_OR_SUBSEQUENT" if initial is None else "INITIAL" if initial else "SUBSEQUENT" + return [c for c in self.get(fileable_type=timing, with_names="true") if not is_non_filing_court(c["name"])] def categories(self, court, initial=True): return self.get( @@ -601,12 +602,27 @@ def corrected_search_query(index, query): return " ".join(query_words), corrected +def eligible_court_codes(jurisdiction, initial): + """Apply current destination eligibility even to previously imported paths. + + Cache one small list per jurisdiction/timing, never one request per result. + Failed reads propagate to the search API and are not cached as empty lists. + """ + key = f"filing-court-eligibility:{jurisdiction}:{initial}" + codes = cache.get(key) + if codes is None: + with closing(CodeCatalog(jurisdiction, timeout=(5, 8))) as catalog: + codes = sorted(court["code"] for court in catalog.courts(initial)) + cache.set(key, codes, 600) + return codes + + def matching_paths(index, query, initial): resolved_query, corrected = corrected_search_query(index, query) tokens = search_tokens(resolved_query, index.jurisdiction, query=True) if not tokens: return index.paths.none().annotate(score=Value(0)), [] - paths = index.paths.filter(initial=initial) + paths = index.paths.filter(initial=initial, court__code__in=eligible_court_codes(index.jurisdiction, initial)) if connection.vendor == "postgresql": search = SearchQuery(" ".join(sorted(tokens)), config="simple") paths = paths.annotate(document=SearchVector("search_text", config="simple")).filter(document=search) @@ -930,8 +946,11 @@ def filter_filing_groups( def filing_groups(index, query, initial, courts=()): - fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}".encode()).hexdigest() - key = f"filing-groups-v8:{fingerprint}" + eligible = eligible_court_codes(index.jurisdiction, initial) + fingerprint = hashlib.sha256( + f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}:{eligible}".encode() + ).hexdigest() + key = f"filing-groups-v9:{fingerprint}" cached = cache.get(key) if cached is not None: return cached @@ -1163,7 +1182,7 @@ def find(options, facet): return chosen try: - options = {"court": catalog.courts()} + options = {"court": catalog.courts(path.initial)} court = find(options["court"], "court") options["case_category"] = catalog.categories(court["code"], path.initial) category = find(options["case_category"], "case_category") diff --git a/efile_app/efile/tests/test_court_selection.py b/efile_app/efile/tests/test_court_selection.py index e748fdde..e128d8de 100644 --- a/efile_app/efile/tests/test_court_selection.py +++ b/efile_app/efile/tests/test_court_selection.py @@ -404,25 +404,28 @@ def test_a_county_that_only_heads_its_divisions_is_not_offered(self): {"code": "cook:cvd1", "name": "Cook County - Municipal Civil - District 1 - Chicago"}, {"code": "will", "name": "Will County"}, ] - # The e-filing service leaves both Cook rows out of its fileable list, - # but only the county is a dead end: Chancery has locations under it and - # takes filings of its own, so the flag alone is not enough to drop it. - courts = self._fetch(payload, [{"code": "cook:cvd1"}, {"code": "will"}]) - assert [court["value"] for court in courts] == ["cook:chd", "cook:cvd1", "will"] + courts = self._fetch(payload, [payload[2], payload[3]]) + assert [court["value"] for court in courts] == ["cook:cvd1", "will"] - def test_nothing_is_dropped_when_the_fileable_list_cannot_be_read(self): - payload = [ - {"code": "cook", "name": "Cook County"}, - {"code": "cook:cvd1", "name": "Cook County - Municipal Civil - District 1 - Chicago"}, - ] + def test_parent_with_published_codes_is_not_a_filing_destination(self): + parent = {"code": "cook:cvd", "name": "Cook County - Municipal Civil"} + child = {"code": "cook:cvd2", "name": "Cook County - Municipal Civil - District 2 - Skokie"} + assert self._fetch([parent, child], [child]) == [{"value": child["code"], "text": child["name"]}] + + def test_fileable_parent_is_preserved_even_with_child_locations(self): + parent = {"code": "parent", "name": "Parent court"} + child = {"code": "parent:child", "name": "Child court"} + assert len(self._fetch([parent, child], [parent, child])) == 2 + + def test_unreadable_fileable_list_does_not_offer_unverified_courts(self): with ( patch("efile.services.court_selection.requests.get") as get, patch("efile.services.court_selection.cache") as cache, ): cache.get.return_value = None - get.return_value.json.side_effect = [payload, ValueError("not json")] - get.return_value.raise_for_status.return_value = None - assert len(fetch_courts("illinois")) == 2 + get.return_value.json.side_effect = ValueError("not json") + with pytest.raises(ValueError): + fetch_courts("illinois") @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_filing_code_copy.py b/efile_app/efile/tests/test_filing_code_copy.py index 9597aa35..440e0707 100644 --- a/efile_app/efile/tests/test_filing_code_copy.py +++ b/efile_app/efile/tests/test_filing_code_copy.py @@ -186,3 +186,9 @@ def test_only_superusers_reach_the_filing_codes_page(client, django_user_model): assert client.get(reverse("litefile_staff:filing_codes")).status_code == 403 assert client.post(reverse("litefile_staff:filing_codes"), {"kind": "resync"}).status_code == 403 assert not FilingCodeJob.objects.exists() + + +@pytest.fixture(autouse=True) +def court_eligibility(): + with patch("efile.services.filing_code_search.eligible_court_codes", return_value=["adams", "cook:cvd1"]): + yield diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index 17932e29..266b2e2f 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -6,6 +6,7 @@ import pytest import requests +from django.core.cache import cache from django.core.management import call_command from django.core.management.base import CommandError from django.db import connection, transaction @@ -38,6 +39,7 @@ def legacy_catalog_transport(settings): # These tests exercise the legacy reader and shared search/validation behavior. # The bulk transport and incremental imports have separate contract tests. settings.FILING_CODE_SYNC_MODE = "legacy" + cache.clear() def option(code, name): @@ -1219,3 +1221,53 @@ def test_search_index_leaves_import_snapshots_lazy(index, django_assert_num_quer assert loaded.get_deferred_fields() == {"court_snapshots"} with django_assert_num_queries(1): assert loaded.court_snapshots == expected_snapshots + + +def test_search_excludes_indexed_nonfileable_court(index, catalog): + catalog.courts.return_value = [option("housing", "Boston Housing Court")] + paths, _ = matching_paths(index, "eviction", True) + assert set(paths.values_list("court__code", flat=True)) == {"housing"} + catalog.courts.assert_called_with(True) + + +def test_court_eligibility_depends_on_filing_timing(index, catalog): + catalog.courts.side_effect = lambda initial: [ + option("housing", "Boston Housing Court") if initial else option("district", "Boston District Court") + ] + initial_paths, _ = matching_paths(index, "eviction", True) + subsequent_paths, _ = matching_paths(index, "eviction", False) + assert set(initial_paths.values_list("court__code", flat=True)) == {"housing"} + assert set(subsequent_paths.values_list("court__code", flat=True)) == {"district"} + + +def test_live_validation_rejects_nonfileable_court_early(index, catalog): + path = index.paths.filter(initial=True).first() + catalog.courts.return_value = [] + catalog.categories.reset_mock() + with pytest.raises(ValueError, match="no longer offered"): + validate_path(path) + catalog.courts.assert_called_with(True) + catalog.categories.assert_not_called() + + +@pytest.mark.parametrize("initial,timing", [(None, "INITIAL_OR_SUBSEQUENT"), (True, "INITIAL"), (False, "SUBSEQUENT")]) +def test_catalog_requests_fileable_courts_for_timing(initial, timing): + with patch("efile.services.filing_code_search.requests.Session") as session: + session.return_value.get.return_value.json.return_value = [option("court", "Court")] + api = CodeCatalog("illinois") + try: + assert api.courts(initial) == [option("court", "Court")] + assert session.return_value.get.call_args.kwargs["params"] == { + "fileable_type": timing, + "with_names": "true", + } + finally: + api.close() + + +def test_search_eligibility_failure_is_retryable(signed_in, index, catalog): + catalog.courts.side_effect = requests.Timeout() + params = {"jurisdiction": "massachusetts", "q": "eviction"} + assert signed_in.get(URL, params).status_code == 503 + catalog.courts.side_effect = None + assert signed_in.get(URL, params).status_code == 200 diff --git a/efile_app/efile/tests/test_filing_code_sync.py b/efile_app/efile/tests/test_filing_code_sync.py index 3934ea0e..1a1b88c6 100644 --- a/efile_app/efile/tests/test_filing_code_sync.py +++ b/efile_app/efile/tests/test_filing_code_sync.py @@ -194,3 +194,9 @@ def test_court_emptied_by_a_partial_proxy_load_is_rejected_unless_forced(bulk): assert set(index.paths.values_list("pk", flat=True)) == ids assert refresh_index("massachusetts", force=["housing"]) == 0 assert not index.paths.filter(court__code="housing").exists() + + +@pytest.fixture(autouse=True) +def court_eligibility(): + with patch("efile.services.filing_code_search.eligible_court_codes", return_value=["housing", "district"]): + yield From 132f84b1acfebf1f00314556b6c0fc4a8d119845 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 21:51:21 -0400 Subject: [PATCH 3/7] Allow review navigation after a fee quote error --- efile_app/efile/static/js/payment.js | 4 ++- efile_app/efile/templates/efile/payment.html | 1 + efile_app/efile/tests/test_managed_waiver.py | 19 +++++++++++ efile_app/js-tests/payment.test.js | 33 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js index 7ae5b710..dbfe33f9 100644 --- a/efile_app/efile/static/js/payment.js +++ b/efile_app/efile/static/js/payment.js @@ -59,7 +59,9 @@ const PaymentPage = { setFeesState(loading) { document.getElementById("loadingSpinner").style.display = loading ? "block" : "none"; - document.getElementById("submitButton").disabled = loading || this.removingAccount || this.waiverUploading || DocumentChecks.pending() || !this.feeQuoteReady || !document.getElementById("selected-payment-account").value; + // Review is also where filers correct answers that caused a fee error. + // A confirmed quote is required for submission, not for opening Review. + document.getElementById("submitButton").disabled = loading || this.removingAccount || this.waiverUploading || DocumentChecks.pending() || !document.getElementById("selected-payment-account").value; }, // A copy added on this page is confirmed here before Review. diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html index b40e0c01..c4a3b12f 100644 --- a/efile_app/efile/templates/efile/payment.html +++ b/efile_app/efile/templates/efile/payment.html @@ -152,6 +152,7 @@

{% translate "Confirmed fees" %}

diff --git a/efile_app/efile/tests/test_managed_waiver.py b/efile_app/efile/tests/test_managed_waiver.py index a30e5703..9c369adb 100644 --- a/efile_app/efile/tests/test_managed_waiver.py +++ b/efile_app/efile/tests/test_managed_waiver.py @@ -186,3 +186,22 @@ def test_statutory_exemption_choice_does_not_claim_low_income(client, payment_dr assert "I think I qualify for a fee waiver" not in body assert "You do not need to qualify based on income" in body assert "Add fee waiver documents" not in body + + +def test_payment_without_a_fee_quote_can_open_review_for_corrections(client, payment_draft): + assert payment_draft.quoted_fee_total == "" + with patch( + "efile.views.payment.payment_accounts", + return_value=[{"paymentAccountID": "card", "paymentAccountTypeCode": "CC", "accountName": "My card"}], + ): + result = client.post( + reverse("payment", kwargs={"jurisdiction": "illinois"}), + {"selected_payment_account": "card"}, + ) + assert result.status_code == 302 + review = client.get(result.url) + assert review.status_code == 200 + assert "return_to=review" in review.content.decode() + payment_draft.refresh_from_db() + assert payment_draft.selected_payment_account_id == "card" + assert payment_draft.quoted_fee_total == "" diff --git a/efile_app/js-tests/payment.test.js b/efile_app/js-tests/payment.test.js index 7c86ca19..fba6f3f3 100644 --- a/efile_app/js-tests/payment.test.js +++ b/efile_app/js-tests/payment.test.js @@ -60,6 +60,7 @@ function pageHarness(post, checks = { vm.runInContext(fs.readFileSync(path.join(__dirname, "../efile/static/js/payment.js"), "utf8") + "\nglobalThis.payment = PaymentPage;", context); return { + context, payment: context.payment, window: context.window, apiUtils: context.apiUtils, @@ -350,4 +351,36 @@ test("a copy still waiting to be checked keeps review disabled", async () => { pending = false; payment.setFeesState(false); assert.equal(node("submitButton").disabled, false); +}); + +test("a rejected fee quote still lets the filer continue to Review", async () => { + const { + payment, + node, + apiUtils, + context + } = pageHarness(async () => { + throw { + serverMessage: "This court cannot accept an initial filing" + }; + }); + context.ApiUtils = { + FEE_TIMEOUT_MS: 1000 + }; + context.FilingPayload.userDataFromCaseData = () => ({}); + context.document.querySelector = () => ({ + value: "card", + dataset: { + name: "My card", + type: "CC" + } + }); + apiUtils.getUploadData = async () => ({}); + payment.buildEFilingData = () => ({}); + await payment.selectAndQuote(); + assert.equal(payment.feeQuoteReady, false); + assert.equal(node("errorMessage").hidden, false); + assert.match(node("errorText").textContent, /cannot accept an initial filing/); + assert.equal(node("selected-payment-account").value, "card"); + assert.equal(node("submitButton").disabled, false); }); \ No newline at end of file From bc33b9cbc0af2fa5a5eeabf4ff1c4a497a61c1c5 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 22:24:30 -0400 Subject: [PATCH 4/7] Revert "Filter court choices using filing destination eligibility" This reverts commit 106bde7. The EFSP court list's fileable filters (fileable_only=true and every fileable_type) leave out courts that publish case categories and take filings. Checked against efile-test on 2026-10-07: they drop about 130 Massachusetts courts, including nearly every District Court, every Probate and Family Court and every BMC court, and Cook County's Chancery, Law, Probate, County and Domestic Relations divisions. With the filter, those courts disappeared from the court picker, search results and path validation. Go back to the full named list with heading-only rows dropped, which the existing comments and tests already describe. Co-Authored-By: Claude Opus 5.5 --- efile_app/efile/api/dropdown_views.py | 6 ++- efile_app/efile/api/filing_code_search.py | 4 -- efile_app/efile/services/court_selection.py | 52 +++++++++++++++++-- .../efile/services/filing_code_search.py | 33 +++--------- efile_app/efile/tests/test_court_selection.py | 29 +++++------ .../efile/tests/test_filing_code_copy.py | 6 --- .../efile/tests/test_filing_code_search.py | 52 ------------------- .../efile/tests/test_filing_code_sync.py | 6 --- 8 files changed, 73 insertions(+), 115 deletions(-) diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index d9015ea7..d2c25d6b 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -262,8 +262,10 @@ def get_courts(request): # Make API call to external jurisdiction endpoint api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/" - # Published categories do not prove a location accepts filings. - params = {"fileable_only": True, "with_names": True} + # `fileable_only=true` is incomplete on the EFSP test service and + # hides courts that do expose valid filing categories. Later + # dropdown calls still validate the chosen court's hierarchy. + params = {"fileable_only": False, "with_names": True} try: # Make the API request with auth tokens if available diff --git a/efile_app/efile/api/filing_code_search.py b/efile_app/efile/api/filing_code_search.py index d8ebe432..29655b99 100644 --- a/efile_app/efile/api/filing_code_search.py +++ b/efile_app/efile/api/filing_code_search.py @@ -133,10 +133,6 @@ def filing_code_search(request): if purpose or document: return JsonResponse({"error": "Use grouped search with filing-type filters."}, status=400) result = search_paths(index, query, initial=initial, offset=offset) - except requests.RequestException: - return JsonResponse( - {"error": "We could not check which courts accept filings. Try again in a moment."}, status=503 - ) except ValueError as error: return JsonResponse({"error": str(error)}, status=409) result.update( diff --git a/efile_app/efile/services/court_selection.py b/efile_app/efile/services/court_selection.py index e090b9ae..007f8f65 100644 --- a/efile_app/efile/services/court_selection.py +++ b/efile_app/efile/services/court_selection.py @@ -90,15 +90,20 @@ def selector_config(jurisdiction: str) -> dict[str, Any] | None: def fetch_courts(jurisdiction: str) -> list[dict[str, str]]: - """Courts explicitly accepting initial or subsequent filings.""" + """Every court the e-filing service will accept a filing for, as options. - cache_key = f"court-selector:fileable-courts:{jurisdiction}" + ``fileable_only`` is unreliable on the test service -- it hides courts that + do offer filing categories -- so the whole named list is fetched and the + rows that are plainly not courts are dropped here instead. + """ + + cache_key = f"court-selector:courts:{jurisdiction}" cached = cache.get(cache_key) if cached is not None: return cached url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/" - response = requests.get(url, params={"fileable_only": True, "with_names": True}, timeout=15) + response = requests.get(url, params={"fileable_only": False, "with_names": True}, timeout=15) response.raise_for_status() payload = response.json() courts = [ @@ -107,6 +112,8 @@ def fetch_courts(jurisdiction: str) -> list[dict[str, str]]: if isinstance(court, dict) and court.get("code") and court.get("name") if not is_non_filing_court(court["name"]) ] + fileable = _fileable_codes(jurisdiction) + courts = [court for court in courts if not _is_heading_only(court, courts, fileable)] rules = (selector_config(jurisdiction) or {}).get("court_names") or [] for court in courts: court["text"] = _readable_name(court["text"], rules) @@ -136,6 +143,45 @@ def _readable_name(name: str, rules: list[dict[str, str]]) -> str: return name +def _fileable_codes(jurisdiction: str) -> set[str] | None: + """The courts the e-filing service says accept filings, or ``None``. + + Taken as a hint rather than as the truth. It is demonstrably incomplete -- + Cook County's Chancery division is missing from it and offers three case + categories -- so it is only ever used to confirm something already suspected + from the shape of the list, and a request that fails changes nothing. + """ + + try: + response = requests.get( + f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/", + params={"fileable_only": True, "with_names": True}, + timeout=15, + ) + response.raise_for_status() + return {str(court["code"]) for court in response.json() if isinstance(court, dict) and court.get("code")} + except (requests.RequestException, ValueError, KeyError): + logger.warning("Could not read the fileable court list for %s", jurisdiction) + return None + + +def _is_heading_only(court: dict[str, str], courts: list[dict[str, str]], fileable: set[str] | None) -> bool: + """Whether a court is only a heading over the courts underneath it. + + "Cook County" is such a row: every Cook filing goes to one of the eighty + locations whose code hangs off it, and choosing the county itself is a dead + end -- the case category list comes back empty and the filer is stuck with + no way to see why. Both things have to be true to drop it, because a + division like Cook County - Chancery has locations under it and still takes + filings of its own. + """ + + if fileable is None or court["value"] in fileable: + return False + prefix = f"{court['value']}:" + return any(other["value"].startswith(prefix) for other in courts) + + def _fill(template: Any, answers: dict[str, str]) -> Any: """Substitute ``{step_id}`` placeholders with the answers given so far.""" diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index aeb24061..b3f72ee7 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -2,7 +2,7 @@ Snowball stems and YAML concepts are materialized when refreshing the index. PostgreSQL uses an indexed simple tsvector; SQLite uses an FTS5 inverted index. -Court eligibility is cached briefly; selection is revalidated live. +No external service is called while searching. Selection is revalidated live. """ from __future__ import annotations @@ -303,9 +303,8 @@ def get(self, suffix="", **params): result[code] = {"code": code, "name": str(item["name"])} return list(result.values()) - def courts(self, initial=None): - timing = "INITIAL_OR_SUBSEQUENT" if initial is None else "INITIAL" if initial else "SUBSEQUENT" - return [c for c in self.get(fileable_type=timing, with_names="true") if not is_non_filing_court(c["name"])] + def courts(self): + return [c for c in self.get(fileable_only="false", with_names="true") if not is_non_filing_court(c["name"])] def categories(self, court, initial=True): return self.get( @@ -602,27 +601,12 @@ def corrected_search_query(index, query): return " ".join(query_words), corrected -def eligible_court_codes(jurisdiction, initial): - """Apply current destination eligibility even to previously imported paths. - - Cache one small list per jurisdiction/timing, never one request per result. - Failed reads propagate to the search API and are not cached as empty lists. - """ - key = f"filing-court-eligibility:{jurisdiction}:{initial}" - codes = cache.get(key) - if codes is None: - with closing(CodeCatalog(jurisdiction, timeout=(5, 8))) as catalog: - codes = sorted(court["code"] for court in catalog.courts(initial)) - cache.set(key, codes, 600) - return codes - - def matching_paths(index, query, initial): resolved_query, corrected = corrected_search_query(index, query) tokens = search_tokens(resolved_query, index.jurisdiction, query=True) if not tokens: return index.paths.none().annotate(score=Value(0)), [] - paths = index.paths.filter(initial=initial, court__code__in=eligible_court_codes(index.jurisdiction, initial)) + paths = index.paths.filter(initial=initial) if connection.vendor == "postgresql": search = SearchQuery(" ".join(sorted(tokens)), config="simple") paths = paths.annotate(document=SearchVector("search_text", config="simple")).filter(document=search) @@ -946,11 +930,8 @@ def filter_filing_groups( def filing_groups(index, query, initial, courts=()): - eligible = eligible_court_codes(index.jurisdiction, initial) - fingerprint = hashlib.sha256( - f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}:{eligible}".encode() - ).hexdigest() - key = f"filing-groups-v9:{fingerprint}" + fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}".encode()).hexdigest() + key = f"filing-groups-v8:{fingerprint}" cached = cache.get(key) if cached is not None: return cached @@ -1182,7 +1163,7 @@ def find(options, facet): return chosen try: - options = {"court": catalog.courts(path.initial)} + options = {"court": catalog.courts()} court = find(options["court"], "court") options["case_category"] = catalog.categories(court["code"], path.initial) category = find(options["case_category"], "case_category") diff --git a/efile_app/efile/tests/test_court_selection.py b/efile_app/efile/tests/test_court_selection.py index e128d8de..e748fdde 100644 --- a/efile_app/efile/tests/test_court_selection.py +++ b/efile_app/efile/tests/test_court_selection.py @@ -404,28 +404,25 @@ def test_a_county_that_only_heads_its_divisions_is_not_offered(self): {"code": "cook:cvd1", "name": "Cook County - Municipal Civil - District 1 - Chicago"}, {"code": "will", "name": "Will County"}, ] - courts = self._fetch(payload, [payload[2], payload[3]]) - assert [court["value"] for court in courts] == ["cook:cvd1", "will"] + # The e-filing service leaves both Cook rows out of its fileable list, + # but only the county is a dead end: Chancery has locations under it and + # takes filings of its own, so the flag alone is not enough to drop it. + courts = self._fetch(payload, [{"code": "cook:cvd1"}, {"code": "will"}]) + assert [court["value"] for court in courts] == ["cook:chd", "cook:cvd1", "will"] - def test_parent_with_published_codes_is_not_a_filing_destination(self): - parent = {"code": "cook:cvd", "name": "Cook County - Municipal Civil"} - child = {"code": "cook:cvd2", "name": "Cook County - Municipal Civil - District 2 - Skokie"} - assert self._fetch([parent, child], [child]) == [{"value": child["code"], "text": child["name"]}] - - def test_fileable_parent_is_preserved_even_with_child_locations(self): - parent = {"code": "parent", "name": "Parent court"} - child = {"code": "parent:child", "name": "Child court"} - assert len(self._fetch([parent, child], [parent, child])) == 2 - - def test_unreadable_fileable_list_does_not_offer_unverified_courts(self): + def test_nothing_is_dropped_when_the_fileable_list_cannot_be_read(self): + payload = [ + {"code": "cook", "name": "Cook County"}, + {"code": "cook:cvd1", "name": "Cook County - Municipal Civil - District 1 - Chicago"}, + ] with ( patch("efile.services.court_selection.requests.get") as get, patch("efile.services.court_selection.cache") as cache, ): cache.get.return_value = None - get.return_value.json.side_effect = ValueError("not json") - with pytest.raises(ValueError): - fetch_courts("illinois") + get.return_value.json.side_effect = [payload, ValueError("not json")] + get.return_value.raise_for_status.return_value = None + assert len(fetch_courts("illinois")) == 2 @pytest.mark.django_db diff --git a/efile_app/efile/tests/test_filing_code_copy.py b/efile_app/efile/tests/test_filing_code_copy.py index 440e0707..9597aa35 100644 --- a/efile_app/efile/tests/test_filing_code_copy.py +++ b/efile_app/efile/tests/test_filing_code_copy.py @@ -186,9 +186,3 @@ def test_only_superusers_reach_the_filing_codes_page(client, django_user_model): assert client.get(reverse("litefile_staff:filing_codes")).status_code == 403 assert client.post(reverse("litefile_staff:filing_codes"), {"kind": "resync"}).status_code == 403 assert not FilingCodeJob.objects.exists() - - -@pytest.fixture(autouse=True) -def court_eligibility(): - with patch("efile.services.filing_code_search.eligible_court_codes", return_value=["adams", "cook:cvd1"]): - yield diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index 266b2e2f..17932e29 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -6,7 +6,6 @@ import pytest import requests -from django.core.cache import cache from django.core.management import call_command from django.core.management.base import CommandError from django.db import connection, transaction @@ -39,7 +38,6 @@ def legacy_catalog_transport(settings): # These tests exercise the legacy reader and shared search/validation behavior. # The bulk transport and incremental imports have separate contract tests. settings.FILING_CODE_SYNC_MODE = "legacy" - cache.clear() def option(code, name): @@ -1221,53 +1219,3 @@ def test_search_index_leaves_import_snapshots_lazy(index, django_assert_num_quer assert loaded.get_deferred_fields() == {"court_snapshots"} with django_assert_num_queries(1): assert loaded.court_snapshots == expected_snapshots - - -def test_search_excludes_indexed_nonfileable_court(index, catalog): - catalog.courts.return_value = [option("housing", "Boston Housing Court")] - paths, _ = matching_paths(index, "eviction", True) - assert set(paths.values_list("court__code", flat=True)) == {"housing"} - catalog.courts.assert_called_with(True) - - -def test_court_eligibility_depends_on_filing_timing(index, catalog): - catalog.courts.side_effect = lambda initial: [ - option("housing", "Boston Housing Court") if initial else option("district", "Boston District Court") - ] - initial_paths, _ = matching_paths(index, "eviction", True) - subsequent_paths, _ = matching_paths(index, "eviction", False) - assert set(initial_paths.values_list("court__code", flat=True)) == {"housing"} - assert set(subsequent_paths.values_list("court__code", flat=True)) == {"district"} - - -def test_live_validation_rejects_nonfileable_court_early(index, catalog): - path = index.paths.filter(initial=True).first() - catalog.courts.return_value = [] - catalog.categories.reset_mock() - with pytest.raises(ValueError, match="no longer offered"): - validate_path(path) - catalog.courts.assert_called_with(True) - catalog.categories.assert_not_called() - - -@pytest.mark.parametrize("initial,timing", [(None, "INITIAL_OR_SUBSEQUENT"), (True, "INITIAL"), (False, "SUBSEQUENT")]) -def test_catalog_requests_fileable_courts_for_timing(initial, timing): - with patch("efile.services.filing_code_search.requests.Session") as session: - session.return_value.get.return_value.json.return_value = [option("court", "Court")] - api = CodeCatalog("illinois") - try: - assert api.courts(initial) == [option("court", "Court")] - assert session.return_value.get.call_args.kwargs["params"] == { - "fileable_type": timing, - "with_names": "true", - } - finally: - api.close() - - -def test_search_eligibility_failure_is_retryable(signed_in, index, catalog): - catalog.courts.side_effect = requests.Timeout() - params = {"jurisdiction": "massachusetts", "q": "eviction"} - assert signed_in.get(URL, params).status_code == 503 - catalog.courts.side_effect = None - assert signed_in.get(URL, params).status_code == 200 diff --git a/efile_app/efile/tests/test_filing_code_sync.py b/efile_app/efile/tests/test_filing_code_sync.py index 1a1b88c6..3934ea0e 100644 --- a/efile_app/efile/tests/test_filing_code_sync.py +++ b/efile_app/efile/tests/test_filing_code_sync.py @@ -194,9 +194,3 @@ def test_court_emptied_by_a_partial_proxy_load_is_rejected_unless_forced(bulk): assert set(index.paths.values_list("pk", flat=True)) == ids assert refresh_index("massachusetts", force=["housing"]) == 0 assert not index.paths.filter(court__code="housing").exists() - - -@pytest.fixture(autouse=True) -def court_eligibility(): - with patch("efile.services.filing_code_search.eligible_court_codes", return_value=["housing", "district"]): - yield From ac3a293eef83d519b7a50862dcd54427d2517c6b Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 22:24:45 -0400 Subject: [PATCH 5/7] Show the continue-to-Review note only for fee quote errors The note sat in the shared payment error box, so it also appeared for errors like a failed payment-method removal or an expired sign-in, where continuing to Review is not the answer. Co-Authored-By: Claude Opus 5.5 --- efile_app/efile/static/js/payment.js | 14 +++++++++++--- efile_app/efile/templates/efile/payment.html | 2 +- efile_app/js-tests/payment.test.js | 3 +++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/efile_app/efile/static/js/payment.js b/efile_app/efile/static/js/payment.js index dbfe33f9..15efe518 100644 --- a/efile_app/efile/static/js/payment.js +++ b/efile_app/efile/static/js/payment.js @@ -23,8 +23,12 @@ const paymentMessages = { document.getElementById("errorMessage").hidden = true; document.getElementById("successMessage").hidden = true; }, - showError(message, actions = []) { + // Only a fee quote error is one the filer can go on past to Review. + showError(message, actions = [], { + feeQuote = false + } = {}) { document.getElementById("errorText").textContent = message; + document.getElementById("feeQuoteHelp").hidden = !feeQuote; window.FilingErrorActions?.render("filing-error-actions", actions); const box = document.getElementById("errorMessage"); box.hidden = false; @@ -269,12 +273,16 @@ const PaymentPage = { if (result?.success && result.quote_superseded) { paymentMessages.showError(gettext("This filing changed while we were calculating fees, perhaps in another window. Reload this page to calculate them again.")); } else if (result?.success && !result.quote_recorded) { - paymentMessages.showError(gettext("The court did not return a fee total for this filing. Try again, or contact the court before you file.")); + paymentMessages.showError(gettext("The court did not return a fee total for this filing. Try again, or contact the court before you file."), [], { + feeQuote: true + }); } } catch (error) { if (currentRequestId !== this.quoteRequestId) return; this.feeQuoteReady = false; - paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again."), error?.errorActions); + paymentMessages.showError(error?.serverMessage || gettext("We could not calculate fees. Please try again."), error?.errorActions, { + feeQuote: true + }); this.setFeesState(false); } }, diff --git a/efile_app/efile/templates/efile/payment.html b/efile_app/efile/templates/efile/payment.html index c4a3b12f..1e1f057e 100644 --- a/efile_app/efile/templates/efile/payment.html +++ b/efile_app/efile/templates/efile/payment.html @@ -152,7 +152,7 @@

{% translate "Confirmed fees" %}

diff --git a/efile_app/js-tests/payment.test.js b/efile_app/js-tests/payment.test.js index fba6f3f3..b94c339d 100644 --- a/efile_app/js-tests/payment.test.js +++ b/efile_app/js-tests/payment.test.js @@ -315,6 +315,7 @@ test("failed removal keeps the existing payment selection usable and permits ret node("selected-payment-account").value = "card"; node("selected-payment-account-name").value = "My card"; node("paymentSection").hidden = false; + node("feeQuoteHelp").hidden = false; payment.feeQuoteReady = true; payment.quoteRequestId = 7; apiUtils.delete = async () => { @@ -330,6 +331,7 @@ test("failed removal keeps the existing payment selection usable and permits ret assert.equal(button.disabled, false); assert.equal(payment.removingAccount, false); assert.equal(node("errorMessage").hidden, false); + assert.equal(node("feeQuoteHelp").hidden, true); assert.equal(node("selected-payment-account").value, "card"); assert.equal(node("selected-payment-account-name").value, "My card"); assert.equal(node("paymentSection").hidden, false); @@ -381,6 +383,7 @@ test("a rejected fee quote still lets the filer continue to Review", async () => assert.equal(payment.feeQuoteReady, false); assert.equal(node("errorMessage").hidden, false); assert.match(node("errorText").textContent, /cannot accept an initial filing/); + assert.equal(node("feeQuoteHelp").hidden, false); assert.equal(node("selected-payment-account").value, "card"); assert.equal(node("submitButton").disabled, false); }); \ No newline at end of file From 1a6ea7df251f941cd39bdb84aa15ebf93d779210 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 22:44:30 -0400 Subject: [PATCH 6/7] Drop division headings from court choices without the fileable filter Cook County - Municipal Civil ("cook:cvd") publishes filing codes but the EFSP refuses filings there; filings go to a district location such as "cook:cvd1". The fileable filter cannot be used to drop it, because it also leaves out courts that take filings. Drop a court only when the fileable list leaves it out and a court the list does include hangs off its code: "cook" -> "cook:cvd1", or for a code that already has a colon, "cook:cvd" -> "cook:cvd1". A bare number is not a prefix, so Massachusetts court "1" does not head "1036:BA". Against efile-test this drops Cook County and its six division parents, plus the Massachusetts Appeals Court and SJC parent rows, and keeps every court that publishes categories without a fileable location under it. The court picker, the court dropdown API, code search and path validation share heading_court_codes. It is cached for ten minutes; a failed read drops nothing and is cached for a minute, so search never waits on the court service. Co-Authored-By: Claude Opus 5.5 --- efile_app/efile/api/dropdown_views.py | 7 +- efile_app/efile/services/court_selection.py | 83 +++++++++++++------ .../efile/services/filing_code_search.py | 16 ++-- efile_app/efile/tests/conftest.py | 10 +++ efile_app/efile/tests/test_court_selection.py | 22 +++++ .../efile/tests/test_filing_code_search.py | 30 +++++++ 6 files changed, 137 insertions(+), 31 deletions(-) diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index d2c25d6b..8695d187 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -11,7 +11,7 @@ from django.conf import settings from django.views.decorators.http import require_http_methods -from efile.services.court_selection import is_non_filing_court +from efile.services.court_selection import heading_court_codes, is_non_filing_court from efile.services.efsp_payload import parse_optional_services from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request @@ -286,12 +286,15 @@ def get_courts(request): # Transform API data to our dropdown format courts = [] if isinstance(api_data, list): + headings = heading_court_codes( + jurisdiction, [str(court.get("code")) for court in api_data if isinstance(court, dict)] + ) for court in api_data: if isinstance(court, dict) and "code" in court and "name" in court: # Rows that exist only inside Tyler -- test # fixtures, retired locations -- are not courts # anyone can file into. - if is_non_filing_court(court["name"]): + if is_non_filing_court(court["name"]) or str(court["code"]) in headings: continue courts.append({"value": court["code"], "text": court["name"]}) diff --git a/efile_app/efile/services/court_selection.py b/efile_app/efile/services/court_selection.py index 007f8f65..5c0167a9 100644 --- a/efile_app/efile/services/court_selection.py +++ b/efile_app/efile/services/court_selection.py @@ -64,6 +64,8 @@ # How long the live court list is reused for. It changes a few times a year, and # the selector re-reads it on every answer the filer gives. COURT_LIST_TTL_SECONDS = 600 +# How long "nothing is dropped" stands after a court list could not be read. +HEADING_COURTS_RETRY_SECONDS = 60 _PLACEHOLDER = re.compile(r"\{(\w+)\}") @@ -112,8 +114,10 @@ def fetch_courts(jurisdiction: str) -> list[dict[str, str]]: if isinstance(court, dict) and court.get("code") and court.get("name") if not is_non_filing_court(court["name"]) ] - fileable = _fileable_codes(jurisdiction) - courts = [court for court in courts if not _is_heading_only(court, courts, fileable)] + headings = heading_court_codes( + jurisdiction, [str(court.get("code")) for court in payload if isinstance(court, dict)] + ) + courts = [court for court in courts if court["value"] not in headings] rules = (selector_config(jurisdiction) or {}).get("court_names") or [] for court in courts: court["text"] = _readable_name(court["text"], rules) @@ -143,43 +147,74 @@ def _readable_name(name: str, rules: list[dict[str, str]]) -> str: return name -def _fileable_codes(jurisdiction: str) -> set[str] | None: - """The courts the e-filing service says accept filings, or ``None``. +def heading_court_codes(jurisdiction: str, codes: list[str] | None = None) -> frozenset[str]: + """Courts that are only headings over the courts that take filings. + + The court picker, the dropdown API and code search all leave these out, so + a filer is never offered a court the e-filing service will refuse. Cached + briefly; if either list cannot be read, nothing is dropped, and that answer + is cached for a shorter time so search never waits on the service. + """ - Taken as a hint rather than as the truth. It is demonstrably incomplete -- - Cook County's Chancery division is missing from it and offers three case - categories -- so it is only ever used to confirm something already suspected - from the shape of the list, and a request that fails changes nothing. + cache_key = f"court-selector:heading-courts:{jurisdiction}" + cached = cache.get(cache_key) + if cached is not None: + return cached + if codes is None: + codes = _court_codes(jurisdiction, fileable_only=False) + fileable = None if codes is None else _court_codes(jurisdiction, fileable_only=True) + if codes is None or fileable is None: + cache.set(cache_key, frozenset(), HEADING_COURTS_RETRY_SECONDS) + return frozenset() + fileable_codes = set(fileable) + headings = frozenset(code for code in codes if _is_heading_only(code, codes, fileable_codes)) + cache.set(cache_key, headings, COURT_LIST_TTL_SECONDS) + return headings + + +def _court_codes(jurisdiction: str, *, fileable_only: bool) -> list[str] | None: + """One of the e-filing service's court code lists, or ``None``. + + The fileable list is taken as a hint rather than as the truth. It leaves + out courts that publish case categories and take filings -- nearly every + Massachusetts District and Probate and Family Court, and Cook County's + Elder Law locations -- so it only ever confirms what the shape of the list + already suggests, and a request that fails changes nothing. """ try: response = requests.get( f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/", - params={"fileable_only": True, "with_names": True}, - timeout=15, + params={"fileable_only": fileable_only, "with_names": True}, + timeout=5, ) response.raise_for_status() - return {str(court["code"]) for court in response.json() if isinstance(court, dict) and court.get("code")} - except (requests.RequestException, ValueError, KeyError): - logger.warning("Could not read the fileable court list for %s", jurisdiction) + return [str(court["code"]) for court in response.json() if isinstance(court, dict) and court.get("code")] + except (requests.RequestException, ValueError, KeyError, TypeError): + logger.warning("Could not read the court list for %s", jurisdiction) return None -def _is_heading_only(court: dict[str, str], courts: list[dict[str, str]], fileable: set[str] | None) -> bool: - """Whether a court is only a heading over the courts underneath it. +def _is_heading_only(code: str, codes: list[str], fileable: set[str]) -> bool: + """Whether a court is only a heading over fileable courts underneath it. - "Cook County" is such a row: every Cook filing goes to one of the eighty - locations whose code hangs off it, and choosing the county itself is a dead - end -- the case category list comes back empty and the filer is stuck with - no way to see why. Both things have to be true to drop it, because a - division like Cook County - Chancery has locations under it and still takes - filings of its own. + "Cook County" is such a row, and so is each Cook division: filings go to a + district location such as "cook:cvd1", and choosing "cook:cvd" itself is + refused by the e-filing service even though it publishes filing codes. + Both things have to be true to drop a court -- the service leaves it out + of its fileable list, and a court it does list as fileable hangs off its + code -- because the fileable list alone leaves out real courts. """ - if fileable is None or court["value"] in fileable: + if code in fileable: return False - prefix = f"{court['value']}:" - return any(other["value"].startswith(prefix) for other in courts) + return any(other != code and other in fileable and _hangs_off(other, code) for other in codes) + + +def _hangs_off(other: str, code: str) -> bool: + # "cook" -> "cook:cvd1"; "cook:cvd" -> "cook:cvd1". A bare number is not a + # prefix: Massachusetts court "1" does not head court "1036:BA". + return other.startswith(f"{code}:") or (":" in code and other.startswith(code)) def _fill(template: Any, answers: dict[str, str]) -> Any: diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index b3f72ee7..c63821ca 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -48,7 +48,7 @@ validate_case_filters, ) from efile.services.case_type_guidance import case_topic as topic_of -from efile.services.court_selection import is_non_filing_court +from efile.services.court_selection import heading_court_codes, is_non_filing_court from efile.services.filing_availability import filing_unavailable_message from efile.services.glossary import glossary_for @@ -606,7 +606,9 @@ def matching_paths(index, query, initial): tokens = search_tokens(resolved_query, index.jurisdiction, query=True) if not tokens: return index.paths.none().annotate(score=Value(0)), [] - paths = index.paths.filter(initial=initial) + # A court that only heads the locations under it is refused by the e-filing + # service, even when an older index imported filing codes for it. + paths = index.paths.filter(initial=initial).exclude(court__code__in=heading_court_codes(index.jurisdiction)) if connection.vendor == "postgresql": search = SearchQuery(" ".join(sorted(tokens)), config="simple") paths = paths.annotate(document=SearchVector("search_text", config="simple")).filter(document=search) @@ -930,8 +932,11 @@ def filter_filing_groups( def filing_groups(index, query, initial, courts=()): - fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}".encode()).hexdigest() - key = f"filing-groups-v8:{fingerprint}" + headings = sorted(heading_court_codes(index.jurisdiction)) + fingerprint = hashlib.sha256( + f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}:{headings}".encode() + ).hexdigest() + key = f"filing-groups-v9:{fingerprint}" cached = cache.get(key) if cached is not None: return cached @@ -1163,7 +1168,8 @@ def find(options, facet): return chosen try: - options = {"court": catalog.courts()} + headings = heading_court_codes(path.index.jurisdiction) + options = {"court": [court for court in catalog.courts() if court["code"] not in headings]} court = find(options["court"], "court") options["case_category"] = catalog.categories(court["code"], path.initial) category = find(options["case_category"], "case_category") diff --git a/efile_app/efile/tests/conftest.py b/efile_app/efile/tests/conftest.py index 9824b5db..5788d478 100644 --- a/efile_app/efile/tests/conftest.py +++ b/efile_app/efile/tests/conftest.py @@ -41,3 +41,13 @@ def appellate_code_lists(monkeypatch): def efsp_validation_metadata(monkeypatch): """Validation lookup tests opt into metadata; other tests use USPS fallback.""" monkeypatch.setattr("efile.services.efsp_validation.RuleLookups.get", lambda *args: None) + + +@pytest.fixture(autouse=True) +def court_headings(monkeypatch): + """Search and the court dropdown drop no heading courts unless a test opts in. + + Tests of the court lists themselves call court_selection directly. + """ + monkeypatch.setattr("efile.services.filing_code_search.heading_court_codes", lambda *args: frozenset()) + monkeypatch.setattr("efile.api.dropdown_views.heading_court_codes", lambda *args: frozenset()) diff --git a/efile_app/efile/tests/test_court_selection.py b/efile_app/efile/tests/test_court_selection.py index e748fdde..c2f4ebd8 100644 --- a/efile_app/efile/tests/test_court_selection.py +++ b/efile_app/efile/tests/test_court_selection.py @@ -410,6 +410,28 @@ def test_a_county_that_only_heads_its_divisions_is_not_offered(self): courts = self._fetch(payload, [{"code": "cook:cvd1"}, {"code": "will"}]) assert [court["value"] for court in courts] == ["cook:chd", "cook:cvd1", "will"] + def test_a_division_heading_over_fileable_locations_is_not_offered(self): + payload = [ + {"code": "cook:cvd", "name": "Cook County - Municipal Civil"}, + {"code": "cook:cvd2", "name": "Cook County - Municipal Civil - District 2 - Skokie"}, + {"code": "cook:eld1", "name": "Cook County - Elder Law - District 1 - Chicago"}, + ] + # Elder Law takes filings although the fileable list leaves it out; + # only a row heading a location the service does list is dropped. + courts = self._fetch(payload, [payload[1]]) + assert sorted(court["value"] for court in courts) == ["cook:cvd2", "cook:eld1"] + + def test_courts_missing_from_the_fileable_list_are_still_offered(self): + # Massachusetts lists almost none of its District Courts as fileable, + # and a bare number is not a prefix of other courts' codes. + payload = [ + {"code": "1", "name": "Supreme Judicial Court"}, + {"code": "490", "name": "District Court - Cambridge"}, + {"code": "1036:BA", "name": "Juvenile Court -- Barnstable County -- Barnstable"}, + ] + courts = self._fetch(payload, [payload[2]]) + assert sorted(court["value"] for court in courts) == ["1", "1036:BA", "490"] + def test_nothing_is_dropped_when_the_fileable_list_cannot_be_read(self): payload = [ {"code": "cook", "name": "Cook County"}, diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index 17932e29..87b859f3 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -6,12 +6,14 @@ import pytest import requests +from django.core.cache import cache from django.core.management import call_command from django.core.management.base import CommandError from django.db import connection, transaction from django.urls import reverse from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services import court_selection from efile.services.filing_code_search import ( CodeCatalog, concept_definitions, @@ -1219,3 +1221,31 @@ def test_search_index_leaves_import_snapshots_lazy(index, django_assert_num_quer assert loaded.get_deferred_fields() == {"court_snapshots"} with django_assert_num_queries(1): assert loaded.court_snapshots == expected_snapshots + + +def test_search_leaves_out_heading_courts_even_in_an_older_index(index, monkeypatch): + monkeypatch.setattr("efile.services.filing_code_search.heading_court_codes", lambda jurisdiction: {"district"}) + paths, _ = matching_paths(index, "eviction", True) + codes = set(paths.values_list("court__code", flat=True)) + assert codes + assert "district" not in codes + + +def test_live_validation_rejects_a_heading_court(index, catalog, monkeypatch): + path = index.paths.filter(initial=True).first() + monkeypatch.setattr( + "efile.services.filing_code_search.heading_court_codes", lambda jurisdiction: {path.court["code"]} + ) + catalog.categories.reset_mock() + with pytest.raises(ValueError, match="no longer offered"): + validate_path(path) + catalog.categories.assert_not_called() + + +def test_unreadable_court_lists_drop_nothing_and_are_not_refetched_per_search(): + cache.clear() + with patch("efile.services.court_selection.requests.get", side_effect=requests.Timeout()) as get: + assert court_selection.heading_court_codes("massachusetts") == frozenset() + assert court_selection.heading_court_codes("massachusetts") == frozenset() + assert get.call_count == 1 + cache.clear() From 82ccf014ab5f26e0f7e434893bdb9db4959a6cf6 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 22:44:43 -0400 Subject: [PATCH 7/7] Describe division headings in the court list guide Co-Authored-By: Claude Opus 5.5 --- docs/docs/partners-courts/jurisdiction-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index e5868339..1f17e6fb 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -246,7 +246,7 @@ A `courts:` query says which courts an answer leads to. Every rule is optional a | `exclude_code_pattern`, `exclude_name_pattern` | Drop the courts these match. | | `group` | The court belongs to this group — see `group_by` below. | -Courts that are only a heading over the courts beneath them never reach the questions at all. "Cook County" is such a row: every Cook filing goes to one of the eighty locations whose code hangs off it, and choosing the county itself returns an empty case-category list with nothing to explain why. A court is dropped only when the e-filing service leaves it out of its fileable list **and** other courts hang off its code — Cook County - Chancery fails the second test, has locations under it, and takes filings of its own. A caption that names only such a county still routes: the questions it does settle are filled in, and `default_by` can start the rest somewhere sensible. +Courts that are only a heading over the courts beneath them never reach the questions at all. "Cook County" is such a row: every Cook filing goes to one of the eighty locations whose code hangs off it, and choosing the county itself returns an empty case-category list with nothing to explain why. Cook County's divisions are headings too: a filing goes to a district location such as Cook County - Municipal Civil - District 1, and the e-filing service refuses one sent to Municipal Civil itself. A court is dropped only when the e-filing service leaves it out of its fileable list **and** a court that list does include hangs off its code. The fileable list alone is not enough, because it also leaves out courts that take filings, such as most Massachusetts District Courts. The same rule applies to the court lists, to filing code search, and to the check made when a search result is chosen. A caption that names only such a county still routes: the questions it does settle are filled in, and `default_by` can start the rest somewhere sensible. Prefer `name_pattern`. Court **names** are stable and readable; Tyler's codes differ from court to court and change without notice. `{value}`, and any earlier step's id, can be used as a placeholder inside a query.