From 86d0f52ebcf517907f1399ce395f249f1c67ef8b Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 30 Sep 2026 22:03:22 -0400 Subject: [PATCH 1/5] Add court-specific filing availability rules and submission guards --- .../partners-courts/jurisdiction-config.md | 68 ++++++ .../efile/services/filing_availability.py | 70 ++++++ efile_app/efile/services/submission_errors.py | 2 + .../efile/static/config/states/illinois.yaml | 9 + .../templates/efile/extraction_review.html | 1 + .../templates/efile/filing_unavailable.html | 31 +++ .../templates/efile/organize_documents.html | 1 + .../efile/tests/test_filing_availability.py | 231 ++++++++++++++++++ efile_app/efile/views/case_confirmation.py | 3 + efile_app/efile/views/document_checklist.py | 3 + efile_app/efile/views/extraction_review.py | 15 +- efile_app/efile/views/organize_documents.py | 5 + efile_app/efile/views/review.py | 3 + efile_app/efile/views/session_api.py | 13 + efile_app/efile/views/submission.py | 8 + 15 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 efile_app/efile/services/filing_availability.py create mode 100644 efile_app/efile/templates/efile/filing_unavailable.html create mode 100644 efile_app/efile/tests/test_filing_availability.py diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index 4a101f87..41022635 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -392,3 +392,71 @@ three states are saved in `efile_app/efile/tests/fixtures/fee_code_samples.json` for regression tests. State configuration changes refresh the cached state settings on the next request. + +## Temporarily disable filing + +Filing through LITEFile is enabled unless a rule below matches. Put +`filing_availability` under a court in `court_specific_requirements` in +`efile_app/efile/static/config/states/.yaml`. This controls LITEFile, +not whether the court accepts filings through other providers. + +To disable a whole court, set `enabled: false`. An optional `message` explains +why. Without a message, LITEFile says it cannot submit this filing to this court +right now and suggests contacting the clerk about how to file. + +```yaml +court_specific_requirements: + "court-api-code": + filing_availability: + enabled: false + message: "LITEFile cannot file in this court yet. Contact the clerk about how to file." +``` + +To disable only selected types, add `rules`. Each rule can list `case_categories`, +`case_types`, or `filing_types`. Use the court API's **codes**, quoted as strings, +not display names or the semantic keys used by document checklists. Values in a +list are alternatives. If a rule has more than one selector, all selectors must +match. Separate rules are alternatives. A filing-type restriction checks every +document in the envelope, including supporting documents. + +For example, this Cook County configuration demonstrates blocking the Contract +case type in the Municipal Civil Division when hearing scheduling is required +but unsupported by the EFSP: + +```yaml +court_specific_requirements: + "cook:cvd1": + filing_availability: + rules: + - case_types: ["183541"] # Contract in the local browser fixture; verify against the target API. + message: >- + LITEFile cannot file this case type in Cook County yet because it + requires scheduling a hearing. Our e-filing service does not support + hearing scheduling yet. Contact the court clerk to ask how to file. +``` + +This is an example, **not an active restriction or a claim that all Contract +filings require scheduling**. Confirm the affected case-type codes and scheduling +requirements before enabling it. To block only particular filing types within +that case type, add `filing_types: ["verified-filing-type-code"]` to the same rule. +To block a category, use `case_categories: ["verified-category-code"]` instead. + +A `"cook:*"` court entry applies to every court code starting with `cook:`. +This prefix form is only for availability rules; existing checklist overrides +still use exact court keys. Other jurisdictions may use the same `prefix:*` +form if their court codes share a county prefix. Otherwise, repeat the rule for +each affected court code. Codes are always scoped to their jurisdiction. + +The first matching rule supplies its message, falling back to the court's +`message`, then to the built-in text. Exact court settings are checked before +county-prefix settings. Restrictions are additive: `enabled: true` or an empty +rule list on one court does not cancel a county-prefix restriction. A rule with +no selectors does not match; use `enabled: false` to disable a whole court. + +Filers see the restriction when confirming their choices or continuing with +selected filing types. LITEFile also checks saved drafts on the checklist and +review pages, and rechecks both the saved draft and outgoing codes before +submission. Drafts and documents remain available for correction. Removing the +matching restriction restores filing; already-open pages are checked again on +the next request. Restart application workers when deploying configuration +changes, as with other jurisdiction configuration updates. diff --git a/efile_app/efile/services/filing_availability.py b/efile_app/efile/services/filing_availability.py new file mode 100644 index 00000000..2d2e7d39 --- /dev/null +++ b/efile_app/efile/services/filing_availability.py @@ -0,0 +1,70 @@ +"""Deployment-owned restrictions on filing through LITEFile, matched by API codes.""" + +from django.shortcuts import render +from django.utils.translation import gettext as _ + +from efile.models import FilingDocument +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context + + +def filing_unavailable_message(jurisdiction, court, *, case_category="", case_type="", filing_types=()): + """Return a reason, or an empty string when no restriction matches. + + Exact court settings are checked before a county prefix (``cook:*``). + Rules are additive: an exact court cannot enable a county-wide restriction. + Values within a selector are alternatives; selectors within a rule must all + match. Names from the browser never participate in an availability decision. + """ + courts = config_loader.load_jurisdiction_config(jurisdiction).get("court_specific_requirements") or {} + keys = [court] + if ":" in court: + keys.append(court.split(":", 1)[0] + ":*") + selections = { + "case_categories": {str(case_category)} - {""}, + "case_types": {str(case_type)} - {""}, + "filing_types": {str(value) for value in filing_types if value}, + } + for key in keys: + availability = (courts.get(key) or {}).get("filing_availability") or {} + fallback = availability.get("message") or _( + "LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file." + ) + # Specific explanations take precedence over the court's generic one. + for rule in availability.get("rules") or []: + selectors = [name for name in selections if name in rule] + if selectors and all( + selections[name].intersection(str(value) for value in rule[name]) for name in selectors + ): + return rule.get("message") or fallback + if availability.get("enabled") is False: + return fallback + return "" + + +def draft_unavailable_message(draft): + return filing_unavailable_message( + draft.jurisdiction, + draft.court_code, + case_category=draft.case_category_code, + case_type=draft.case_type_code, + filing_types=FilingDocument.objects.filter(draft=draft).values_list("filing_type_code", flat=True), + ) + + +def unavailable_response(request, draft, message): + """Keep the draft intact and offer corrections without suggesting a false court.""" + case_step = ( + WorkflowStepKey.CASE_LOOKUP + if draft.existing_case == ExistingCase.EXISTING + else WorkflowStepKey.EXTRACTION_REVIEW + ) + context = { + "is_logged_in": True, + "draft": draft, + "availability_message": message, + "change_case_url": get_step_url(case_step, draft.jurisdiction), + "change_documents_url": get_step_url(WorkflowStepKey.ORGANIZE_DOCUMENTS, draft.jurisdiction), + } + context.update(get_workflow_context(draft.current_step, draft.jurisdiction, draft)) + return render(request, "efile/filing_unavailable.html", context, status=403) diff --git a/efile_app/efile/services/submission_errors.py b/efile_app/efile/services/submission_errors.py index fb77ab53..25341afa 100644 --- a/efile_app/efile/services/submission_errors.py +++ b/efile_app/efile/services/submission_errors.py @@ -4,6 +4,7 @@ class SubmissionErrorCode: """Machine-readable codes for errors returned before filing submission.""" + FILING_UNAVAILABLE = "submission_filing_unavailable" CASE_DATA_MISSING = "submission_case_data_missing" UPLOAD_DATA_MISSING = "submission_upload_data_missing" EFILE_DATA_MISSING = "submission_efile_data_missing" @@ -15,6 +16,7 @@ class SubmissionErrorCode: PRE_SUBMIT_ERROR_CODES = frozenset( { + SubmissionErrorCode.FILING_UNAVAILABLE, SubmissionErrorCode.CASE_DATA_MISSING, SubmissionErrorCode.UPLOAD_DATA_MISSING, SubmissionErrorCode.EFILE_DATA_MISSING, diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml index 8e74369e..fd81ec28 100644 --- a/efile_app/efile/static/config/states/illinois.yaml +++ b/efile_app/efile/static/config/states/illinois.yaml @@ -527,6 +527,15 @@ court_specific_requirements: requirement: always "cook:cvd1": # Cook County Circuit Court - Municipal Civil Division + # Optional availability hook. Leave commented until affected codes and + # hearing-scheduling requirements are confirmed against the target EFSP. + # filing_availability: + # rules: + # - case_types: ["183541"] # Contract; illustrative, not a live restriction. + # message: >- + # LITEFile cannot file this case type in Cook County yet because it + # requires scheduling a hearing. Our e-filing service does not support + # hearing scheduling yet. Contact the court clerk to ask how to file. case_types: eviction: documents: diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 0334636f..a0cc45d4 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -6,6 +6,7 @@ {% translate "Review what we found" %} {% endblock title %} {% block workflow_content %} + {% if availability_message %}{% endif %}
{% translate "Confirm case" %}
{% ui_text "extraction_review.court_label" as court_label %} diff --git a/efile_app/efile/templates/efile/filing_unavailable.html b/efile_app/efile/templates/efile/filing_unavailable.html new file mode 100644 index 00000000..82ef67a2 --- /dev/null +++ b/efile_app/efile/templates/efile/filing_unavailable.html @@ -0,0 +1,31 @@ +{% extends "efile/workflow_base.html" %} +{% load i18n %} +{% block title %} + {% translate "Filing is unavailable" %} +{% endblock title %} +{% block workflow_content %} +
+

{% translate "Filing is unavailable" %}

+ +
+
+
{% translate "Court" %}
+
+ {{ draft.court_name|default:draft.court_code }} +
+
+
+
{% translate "Case type" %}
+
+ {{ draft.case_type_name|default:draft.case_type_code }} +
+
+
+

{% translate "Your draft is saved. No filing has been sent to the court." %}

+

{% translate "If these details are incorrect, you can correct them below." %}

+ +
+{% endblock workflow_content %} diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index b59a855f..c8c50710 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -6,6 +6,7 @@ {% translate "Organize your documents" %} {% endblock title %} {% block workflow_content %} + {% if availability_message %}{% endif %}
{% translate "Organize documents" %}

{% translate "Organize your documents" %}

diff --git a/efile_app/efile/tests/test_filing_availability.py b/efile_app/efile/tests/test_filing_availability.py new file mode 100644 index 00000000..921f143b --- /dev/null +++ b/efile_app/efile/tests/test_filing_availability.py @@ -0,0 +1,231 @@ +from unittest.mock import Mock + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.filing_availability import draft_unavailable_message, filing_unavailable_message +from efile.tests.helpers import reviewed_document + + +@pytest.fixture +def submission_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="availability-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + workflow_version=2, + existing_case="new", + court_code="cook:law1", + court_name="Cook County", + case_category_code="civil", + case_type_code="contract", + case_type_name="Contract", + ) + reviewed_document( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="Petition.pdf", + filing_type_code="petition", + document_type_code="public", + ) + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "test-token"} + session.save() + return draft + + +@pytest.fixture +def configure(monkeypatch): + def set_config(availability, court="cook:law1"): + monkeypatch.setattr( + "efile.services.filing_availability.config_loader.load_jurisdiction_config", + lambda jurisdiction: {"court_specific_requirements": {court: {"filing_availability": availability}}} + if jurisdiction == "illinois" + else {}, + ) + + return set_config + + +def test_default_enabled_and_jurisdiction_and_court_scope(configure): + configure({"enabled": False}) + assert filing_unavailable_message("illinois", "cook:law1") + assert not filing_unavailable_message("illinois", "cook:law2") + assert not filing_unavailable_message("vermont", "cook:law1") + + +@pytest.mark.parametrize( + "selector,arguments", + [ + ("case_categories", {"case_category": "123"}), + ("case_types", {"case_type": "123"}), + ("filing_types", {"filing_types": ["unrestricted", "123"]}), + ], +) +def test_each_selector_matches_codes_including_numeric_yaml(configure, selector, arguments): + configure({"rules": [{selector: [123], "message": "Scheduling is unavailable."}]}) + assert filing_unavailable_message("illinois", "cook:law1", **arguments) == "Scheduling is unavailable." + assert not filing_unavailable_message("illinois", "cook:law1") + + +def test_rules_combine_selectors_and_fall_back_to_court_message(configure): + configure({"message": "Court notice", "rules": [{"case_types": ["contract"], "filing_types": ["motion"]}]}) + assert not filing_unavailable_message("illinois", "cook:law1", case_type="contract") + assert ( + filing_unavailable_message("illinois", "cook:law1", case_type="contract", filing_types=["motion"]) + == "Court notice" + ) + + +def test_county_prefix_is_bounded_and_specific_message_wins(monkeypatch): + monkeypatch.setattr( + "efile.services.filing_availability.config_loader.load_jurisdiction_config", + lambda jurisdiction: { + "court_specific_requirements": { + "cook:*": {"filing_availability": {"enabled": False, "message": "County notice"}}, + "cook:law1": { + "filing_availability": { + "enabled": True, + "rules": [{"case_types": ["contract"], "message": "Hearing scheduling is unavailable."}], + } + }, + } + }, + ) + assert ( + filing_unavailable_message("illinois", "cook:law1", case_type="contract") + == "Hearing scheduling is unavailable." + ) + assert filing_unavailable_message("illinois", "cook:law1", case_type="other") == "County notice" + assert filing_unavailable_message("illinois", "cook:cd1") == "County notice" + assert not filing_unavailable_message("illinois", "cooksville:law1") + + +def test_empty_rules_do_not_disable_and_specific_overrides_generic(configure): + configure({"rules": [{"message": "No selector"}, {"case_types": []}]}) + assert not filing_unavailable_message("illinois", "cook:law1") + configure({"enabled": False, "message": "Generic", "rules": [{"case_types": ["contract"], "message": "Specific"}]}) + assert filing_unavailable_message("illinois", "cook:law1", case_type="contract") == "Specific" + + +@pytest.mark.django_db +def test_supporting_document_blocks_envelope_and_removal_restores_filing(configure, submission_draft): + configure({"rules": [{"filing_types": ["motion"]}]}) + assert not draft_unavailable_message(submission_draft) + document = FilingDocument.objects.create( + draft=submission_draft, role="supporting", sort_order=1, filing_type_code="motion" + ) + assert draft_unavailable_message(submission_draft) + document.delete() + assert not draft_unavailable_message(submission_draft) + + +@pytest.mark.django_db +@pytest.mark.parametrize("view", ["document_checklist", "case_review"]) +def test_blocked_page_preserves_draft_and_escapes_message(configure, client, submission_draft, view): + configure({"rules": [{"case_types": ["contract"], "message": "Scheduling "}]}) + response = client.get(reverse(view, kwargs={"jurisdiction": "illinois"})) + assert response.status_code == 403 + assert b"Scheduling <script>" in response.content + assert b"Correct case details" in response.content + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT + assert submission_draft.documents.exists() + + +@pytest.mark.django_db +def test_direct_submit_cannot_bypass_new_restriction(configure, client, submission_draft, monkeypatch): + configure({"rules": [{"case_types": ["contract"], "message": "Hearing scheduling is unavailable."}]}) + forward = Mock() + monkeypatch.setattr("efile.views.submission.forward_final_filing", forward) + response = client.post(reverse("submit_final_filing"), {}, content_type="application/json") + assert response.status_code == 403 + assert response.json()["error_code"] == "submission_filing_unavailable" + forward.assert_not_called() + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT + + +@pytest.mark.django_db +def test_outgoing_payload_codes_are_also_checked(configure, client, submission_draft, monkeypatch): + from django.test import RequestFactory + + from efile.views.session_api import forward_final_filing + + configure({"rules": [{"filing_types": ["blocked"]}]}) + request = RequestFactory().post("/") + request.user = submission_draft.user + request.session = client.session + external = Mock() + monkeypatch.setattr("requests.post", external) + response = forward_final_filing( + request, + {"efile_data": {"al_court_bundle": {"elements": [{"filing_type": "allowed"}, {"filing_type": "blocked"}]}}}, + ) + assert response.status_code == 403 + external.assert_not_called() + + +@pytest.mark.django_db +def test_confirm_new_case_keeps_choices_editable_when_blocked(configure, client, submission_draft): + configure({"rules": [{"case_types": ["blocked"], "message": "Scheduling is unavailable."}]}) + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "existing_case": "new", + "court_code": "cook:law1", + "court_name": "Cook County", + "case_category_code": "civil", + "case_type_code": "blocked", + "case_type_name": "Blocked type", + }, + ) + assert response.status_code == 200 + assert b"Scheduling is unavailable." in response.content + assert response.context["extraction_context"]["case_type_code"] == "blocked" + submission_draft.refresh_from_db() + assert submission_draft.case_type_code == "contract" + + +@pytest.mark.django_db +def test_existing_case_confirmation_is_blocked_but_search_again_works(configure, client, submission_draft): + configure({"enabled": False}) + submission_draft.existing_case = "existing" + submission_draft.previous_case_id = "case-123" + submission_draft.docket_number = "2026-CV-123" + submission_draft.save() + url = reverse("case_confirmation", kwargs={"jurisdiction": "illinois"}) + assert client.post(url, {"confirmed": "yes"}).status_code == 403 + assert client.post(url, {"confirmed": "no"}).status_code == 302 + + +@pytest.mark.django_db +def test_organize_blocks_supporting_type_but_allows_correction(configure, client, submission_draft): + configure({"rules": [{"filing_types": ["motion"], "message": "Scheduling is unavailable."}]}) + document = submission_draft.documents.first() + url = reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + details = {"id": document.pk, "filing_type": "motion", "document_type": "public"} + payload = {"main_document_id": document.pk, "documents": [details]} + response = client.post(url, payload, content_type="application/json") + assert response.status_code == 403 + assert response.json()["error"] == "Scheduling is unavailable." + assert b"Scheduling is unavailable." in client.get(url).content + details["filing_type"] = "petition" + assert client.post(url, payload, content_type="application/json").status_code == 200 + + +@pytest.mark.django_db +def test_claim_rechecks_availability(configure, submission_draft): + from efile.views.submission import _claim_for_submission + + configure({"enabled": False, "message": "Disabled since review"}) + with pytest.raises(ValueError, match="Disabled since review"): + _claim_for_submission(submission_draft, {}) + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT diff --git a/efile_app/efile/views/case_confirmation.py b/efile_app/efile/views/case_confirmation.py index 50bde8e6..145dcd90 100644 --- a/efile_app/efile/views/case_confirmation.py +++ b/efile_app/efile/views/case_confirmation.py @@ -5,6 +5,7 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, write_case_data +from efile.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import link_case_to_plan, remember_case_for_plan from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context @@ -28,6 +29,8 @@ def case_confirmation(request, jurisdiction): if request.method == "POST": if request.POST.get("confirmed") == "yes": + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) # The filer has just told us which court case this matter is. Keep # it on the plan so their next filing goes into the same case # without searching for it again. diff --git a/efile_app/efile/views/document_checklist.py b/efile_app/efile/views/document_checklist.py index f2af5176..132e9cfa 100644 --- a/efile_app/efile/views/document_checklist.py +++ b/efile_app/efile/views/document_checklist.py @@ -9,6 +9,7 @@ from efile.services.current_drafts import ensure_current_draft from efile.services.document_uploads import upload_files from efile.services.drafts import draft_snapshot +from efile.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import ( attach_document_to_item, attach_lead_document, @@ -113,6 +114,8 @@ def document_checklist(request, jurisdiction): current_step=WorkflowStepKey.DOCUMENT_CHECKLIST, workflow_version=2, ) + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) documents = FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at") if not documents.exists(): messages.error(request, "Upload at least one document before checking your filing.") diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 13b8bb14..d561fcea 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -12,6 +12,7 @@ from efile.services.drafts import draft_snapshot, write_case_data from efile.services.extracted_parties import review_rows, save_reviewed_parties from efile.services.extraction_fields import display_extracted_fields, document_summary_details +from efile.services.filing_availability import filing_unavailable_message from efile.services.filing_path import change_filing_path, describe_path_change, filing_path_conflict from efile.workflow import ( RETURN_TO_REVIEW, @@ -131,6 +132,7 @@ def extraction_review(request, jurisdiction): guesses = display_extracted_fields(draft.extracted_guesses or {}) needs_acknowledgement = bool(guesses) acknowledgement_error = False + availability_message = "" if request.method == "POST": existing_case = request.POST.get("existing_case", draft.existing_case) @@ -138,10 +140,20 @@ def extraction_review(request, jurisdiction): case_category_code = request.POST.get("case_category_code", "") case_type_code = request.POST.get("case_type_code", "") + availability_message = filing_unavailable_message( + jurisdiction, + court_code, + case_category=case_category_code, + case_type=case_type_code, + filing_types=[request.POST.get("filing_type_code", "")], + ) + offered_roles = {role["id"] for role in _offered_filer_roles(request, jurisdiction)} filer_role = request.POST.get("filer_role", "") - if needs_acknowledgement and request.POST.get("reviewed_extraction") != "yes": + if availability_message: + pass # Show the persistent notice alongside the editable choices. + elif needs_acknowledgement and request.POST.get("reviewed_extraction") != "yes": # Shown beside the checkbox rather than as a toast, so it stays put # and is tied to the control that needs attention. acknowledgement_error = True @@ -278,6 +290,7 @@ def classified(level, key): ) context = { "is_logged_in": True, + "availability_message": availability_message, "lead_document": lead, "filing_draft": draft_snapshot(draft), "has_guesses": needs_acknowledgement, diff --git a/efile_app/efile/views/organize_documents.py b/efile_app/efile/views/organize_documents.py index 6eabcc9b..4d1ab4f7 100644 --- a/efile_app/efile/views/organize_documents.py +++ b/efile_app/efile/views/organize_documents.py @@ -14,6 +14,7 @@ from efile.models import FilingDocument from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot +from efile.services.filing_availability import draft_unavailable_message from efile.utils.config_loader import config_loader from efile.utils.ui_text import get_texts from efile.workflow import RETURN_TO_REVIEW, ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context @@ -165,6 +166,9 @@ def organize_documents(request, jurisdiction): except (json.JSONDecodeError, ValueError) as error: return JsonResponse({"success": False, "error": str(error)}, status=400) + if message := draft_unavailable_message(draft): + return JsonResponse({"success": False, "error": message}, status=403) + return_to_review = data.get("return_to") == RETURN_TO_REVIEW next_step = WorkflowStepKey.REVIEW if return_to_review else WorkflowStepKey.YOUR_INFORMATION draft.current_step = next_step @@ -180,6 +184,7 @@ def organize_documents(request, jurisdiction): confidentiality_config = jurisdiction_config.get("document_confidentiality") or {} context = { "is_logged_in": True, + "availability_message": draft_unavailable_message(draft), "filing_draft": draft_snapshot(draft), "documents": documents, "return_to": request.GET.get("return_to", ""), diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index c2ad5a7e..6095836c 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -9,6 +9,7 @@ from efile.services.drafts import draft_snapshot, read_case_data, read_upload_data from efile.services.extracted_parties import party_display_name from efile.services.fee_quotes import fee_inputs_token, fee_quote_summary +from efile.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import documents_missing_from_envelope from efile.services.people import get_case_questions @@ -46,6 +47,8 @@ def case_review(request, jurisdiction): current_step=WorkflowStepKey.REVIEW, workflow_version=2, ) + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) if unreviewed_documents(draft).exists(): return redirect(get_step_url(WorkflowStepKey.PREVIEW_DOCUMENTS, jurisdiction) + "?return_to=review") diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 4034c89e..625e39df 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -9,6 +9,7 @@ from ..services.efsp_errors import describe_efsp_error from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..services.extraction_fields import EXTRACTION_FIELDS, EXTRACTION_HINTS +from ..services.filing_availability import filing_unavailable_message from ..services.submission_errors import SubmissionErrorCode from ..utils.case_data_utils import get_case_data, get_upload_data, update_case_data from ..utils.proxy_connection import get_party_type_code_from_api @@ -163,6 +164,18 @@ def forward_final_filing(request, data): status=400, ) + # Check the actual outgoing codes too: the JSON payload is client supplied. + if message := filing_unavailable_message( + jurisdiction_id, + court_id, + case_category=efile_data.get("efile_case_category", ""), + case_type=efile_data.get("efile_case_type", ""), + filing_types=[item.get("filing_type", "") for item in efile_data["al_court_bundle"].get("elements", [])], + ): + return JsonResponse( + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, status=403 + ) + # Same fixups the fee quote applied, so the filing matches the quote. try: prepare_efile_payload(efile_data, jurisdiction_id, court_id) diff --git a/efile_app/efile/views/submission.py b/efile_app/efile/views/submission.py index 193b7e0e..32221120 100644 --- a/efile_app/efile/views/submission.py +++ b/efile_app/efile/views/submission.py @@ -12,6 +12,7 @@ from efile.services.disclaimers import validate_acceptance from efile.services.document_previews import require_document_previews from efile.services.fee_quotes import fee_quote_is_usable +from efile.services.filing_availability import draft_unavailable_message from efile.services.filing_plans import mark_attached_items_filed from efile.services.submission_errors import PRE_SUBMIT_ERROR_CODES, SubmissionErrorCode @@ -38,6 +39,8 @@ def _claim_for_submission(draft: FilingDraft, acceptance: dict) -> bool: locked = FilingDraft.objects.select_for_update().get(pk=draft.pk) if locked.status not in _CLAIMABLE_STATUSES: return False + if message := draft_unavailable_message(locked): + raise ValueError(message) require_document_previews(locked) claimed = FilingDraft.objects.filter(pk=draft.pk, status__in=_CLAIMABLE_STATUSES).update( status=FilingDraft.Status.SUBMITTING, @@ -103,6 +106,11 @@ def submit_final_filing(request): status=400, ) + if message := draft_unavailable_message(draft): + return JsonResponse( + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, status=403 + ) + try: require_document_previews(draft) except ValueError as error: From 13559d1c0df4e0e73a6aaf889cbc0557922c5791 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 30 Sep 2026 22:28:14 -0400 Subject: [PATCH 2/5] Warn immediately when court or filing selections are restricted --- .../partners-courts/jurisdiction-config.md | 8 +- efile_app/efile/api/filing_availability.py | 24 ++++ efile_app/efile/api/urls.py | 2 + .../efile/static/css/reorganized-flow.css | 4 + efile_app/efile/static/js/case-lookup.js | 12 +- .../efile/static/js/extraction-review.js | 17 +++ .../efile/static/js/filing-availability.js | 67 ++++++++++ .../efile/static/js/organize-documents.js | 15 +++ .../templates/efile/case_confirmation.html | 7 +- .../efile/templates/efile/case_lookup.html | 7 + .../templates/efile/extraction_review.html | 8 +- .../templates/efile/organize_documents.html | 8 +- .../efile/tests/test_filing_availability.py | 24 +++- .../efile/tests/test_security_boundaries.py | 4 +- efile_app/efile/views/case_confirmation.py | 1 + efile_app/efile/views/session_api.py | 4 +- efile_app/tests/confirm-case-editing.spec.js | 124 ++++++++++++++++++ 17 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 efile_app/efile/api/filing_availability.py create mode 100644 efile_app/efile/static/js/filing-availability.js diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index 41022635..93a148b2 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -453,8 +453,12 @@ county-prefix settings. Restrictions are additive: `enabled: true` or an empty rule list on one court does not cancel a county-prefix restriction. A rule with no selectors does not match; use `enabled: false` to disable a whole court. -Filers see the restriction when confirming their choices or continuing with -selected filing types. LITEFile also checks saved drafts on the checklist and +Filers see a warning beside their selection as soon as they choose a restricted +court, category, case type, or filing type. Continue is disabled until the check +finishes and the selection is allowed. Changing to an allowed selection clears +the warning. The same checks run on document filing-type changes and court +selection during an existing-case lookup; a restricted case type is shown as +soon as the lookup resolves the case. LITEFile also checks saved drafts on the checklist and review pages, and rechecks both the saved draft and outgoing codes before submission. Drafts and documents remain available for correction. Removing the matching restriction restores filing; already-open pages are checked again on diff --git a/efile_app/efile/api/filing_availability.py b/efile_app/efile/api/filing_availability.py new file mode 100644 index 00000000..f59fa552 --- /dev/null +++ b/efile_app/efile/api/filing_availability.py @@ -0,0 +1,24 @@ +"""Read-only availability checks as a filer changes court and type selections.""" + +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods + +from efile.services.filing_availability import filing_unavailable_message +from efile.utils.config_loader import InvalidJurisdiction + + +@require_http_methods(["GET"]) +def get_filing_availability(request): + try: + message = filing_unavailable_message( + request.GET.get("jurisdiction") or request.session.get("jurisdiction"), + request.GET.get("court", ""), + case_category=request.GET.get("case_category", ""), + case_type=request.GET.get("case_type", ""), + filing_types=request.GET.getlist("filing_type"), + ) + except InvalidJurisdiction as error: + return JsonResponse({"success": False, "error": str(error)}, status=400) + response = JsonResponse({"success": True, "available": not bool(message), "message": message}) + response.headers["Cache-Control"] = "no-store" + return response diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index 56b18c15..0d373b35 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -26,6 +26,7 @@ get_optional_services, get_party_types, ) +from .filing_availability import get_filing_availability from .filing_views import get_filings, payment_fees from .payment_views import delete_payment_account, waiver_account from .suffolk_api_views import get_party_types_from_suffolk_api, lookup_case @@ -44,6 +45,7 @@ path("dropdowns/optional-services/", get_optional_services, name="optional_services"), path("dropdowns/party-types/", get_party_types, name="party_types"), path("dropdowns/name-suffixes/", get_name_suffixes, name="name_suffixes"), + path("filing-availability/", get_filing_availability, name="filing_availability"), # Form configuration endpoints path("form-config/", get_form_config, name="form_config"), path("case-type-config/", get_case_type_config, name="case_type_config"), diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 0885bb7b..aab2c4ae 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -692,6 +692,10 @@ margin-top: 0.3rem; } +.review-field { + min-width: 0; +} + .review-field>span { color: var(--text-heading); display: block; diff --git a/efile_app/efile/static/js/case-lookup.js b/efile_app/efile/static/js/case-lookup.js index fea577c3..14174163 100644 --- a/efile_app/efile/static/js/case-lookup.js +++ b/efile_app/efile/static/js/case-lookup.js @@ -11,6 +11,16 @@ const extractionHelp = document.getElementById("court-extraction-help"); const selectedCourtCode = JSON.parse(document.getElementById("selected-court-code").textContent || '""'); + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: apiUtils.getCurrentJurisdiction(), + court: courtSelect.value + }), + }); + courtSelect.addEventListener("change", () => availability.check(courtSelect.closest(".form-field"))); + async function mountCourtSelector() { const container = document.getElementById("court-selector"); if (!container || !window.courtSelector) return false; @@ -112,5 +122,5 @@ } }); - loadCourts(); + loadCourts().then(() => availability.check(courtSelect.closest(".form-field"))); })(); \ No newline at end of file diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 390783fc..723668f4 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -50,6 +50,18 @@ }, }; + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: context.jurisdiction, + court: fields.court.select.value, + case_category: fields.case_category.select.value, + case_type: fields.case_type.select.value, + filing_types: [fields.filing_type.select.value], + }), + }); + const ORDER = ["court", "case_category", "case_type", "filing_type"]; const DOWNSTREAM = { @@ -180,6 +192,7 @@ text }; field.nameInput.value = text; + availability.check(); if (field.valueEl && code) { field.valueEl.textContent = text + (option.textContent.trim().endsWith("*") ? " *" : ""); } @@ -191,6 +204,7 @@ field.select.disabled = true; field.select.innerHTML = ``; field.root.setAttribute("aria-busy", "true"); + availability.check(); if (field.checking) { field.checking.textContent = gettext("Checking this is still offered…"); field.checking.hidden = false; @@ -214,6 +228,7 @@ field.nameInput.value = ""; field.hint.textContent = field.defaultHint; setMode(key, "edit"); + availability.check(); } function failField(key, message, placeholder) { @@ -357,6 +372,7 @@ setMode(key, "edit"); } if (token !== undefined && !isCurrent(key, token)) return; + availability.check(); await ADVANCE[key](); } @@ -510,6 +526,7 @@ ORDER.forEach((key) => { fields[key].select.addEventListener("change", () => { remember(key); + availability.check(fields[key].root); fields[key].hint.textContent = fields[key].defaultHint; ADVANCE[key](); }); diff --git a/efile_app/efile/static/js/filing-availability.js b/efile_app/efile/static/js/filing-availability.js new file mode 100644 index 00000000..96e01d5e --- /dev/null +++ b/efile_app/efile/static/js/filing-availability.js @@ -0,0 +1,67 @@ +(function() { + function mount({ + form, + notice, + selection + }) { + let generation = 0; + let blocked = true; + let controller; + const buttons = Array.from(form.querySelectorAll('button[type="submit"]')); + + function show(message, unavailable) { + blocked = unavailable; + notice.textContent = message; + notice.hidden = !message; + buttons.forEach((button) => { + button.disabled = unavailable; + }); + } + + async function check(anchor) { + const token = ++generation; + controller?.abort(); + controller = new AbortController(); + if (anchor) anchor.append(notice); + const values = selection(); + if (!values.court) { + show("", false); + return; + } + show(gettext("Checking filing availability…"), true); + const params = new URLSearchParams({ + jurisdiction: values.jurisdiction, + court: values.court, + case_category: values.case_category || "", + case_type: values.case_type || "", + }); + (values.filing_types || []).forEach((value) => params.append("filing_type", value)); + try { + const response = await fetch(`/api/filing-availability/?${params}`, { + signal: controller.signal + }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error("Availability check failed"); + if (token !== generation) return; + show(result.message, !result.available); + } catch (error) { + if (token !== generation || error.name === "AbortError") return; + show(gettext("We could not check filing availability. Reload this page to try again."), true); + } + } + + // Also guard Enter-key submission while a check is pending or blocked. + form.addEventListener("submit", (event) => { + if (!blocked) return; + event.preventDefault(); + event.stopImmediatePropagation(); + notice.focus(); + }, true); + return { + check + }; + } + window.filingAvailability = { + mount + }; +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js index 1bd1f8f5..052568f4 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -32,6 +32,18 @@ // it -- see efile/utils/ui_text.py. const text = context.text || {}; let filingTypes = null; + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: context.jurisdiction, + court: context.court, + case_category: context.case_category, + case_type: context.case_type, + filing_types: cards().map((card) => card.querySelector(".filing-type").value || card.dataset.filingType || ""), + }), + }); + availability.check(); function optionValue(item) { return String(item.value || item.code || item.id || ""); @@ -360,7 +372,10 @@ async function initializeCard(card) { const filingType = card.querySelector(".filing-type"); setOptions(filingType, await loadFilingTypes(), card.dataset.filingType, "Choose a filing type"); + availability.check(); filingType.addEventListener("change", async () => { + card.dataset.filingType = filingType.value; + availability.check(filingType.closest(".form-field") || filingType.parentElement); card.dataset.documentType = ""; card.dataset.filingComponent = ""; try { diff --git a/efile_app/efile/templates/efile/case_confirmation.html b/efile_app/efile/templates/efile/case_confirmation.html index a872e3b9..520cd2c6 100644 --- a/efile_app/efile/templates/efile/case_confirmation.html +++ b/efile_app/efile/templates/efile/case_confirmation.html @@ -61,6 +61,7 @@

{% translate "Is this your court case?" %}

+ {% if availability_message %}{% endif %}
{% csrf_token %}
@@ -74,7 +75,11 @@

{% translate "Is this your court case?" %}

value="no"> {% translate "No, search again" %} -
diff --git a/efile_app/efile/templates/efile/case_lookup.html b/efile_app/efile/templates/efile/case_lookup.html index 699ff8fc..b0e5c205 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -20,6 +20,12 @@

{% translate "Find your court case" %}

{% endif %} {% csrf_token %} +
{% translate "Court" %} @@ -68,6 +74,7 @@

{% translate "Find your court case" %}

{{ selected_court_code|json_script:"selected-court-code" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index a0cc45d4..b23e0ff0 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -6,7 +6,6 @@ {% translate "Review what we found" %} {% endblock title %} {% block workflow_content %} - {% if availability_message %}{% endif %}
{% translate "Confirm case" %}
{% ui_text "extraction_review.court_label" as court_label %} @@ -107,6 +106,12 @@

{% translate "Tell us about your case" %}

{% endif %} {% csrf_token %} +
{{ availability_message }}
{% comment %} First, because the rest depends on it: the court lists different @@ -520,6 +525,7 @@

{% translate "Tell us about your case" %}

{{ extraction_context|json_script:"extraction-context" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index c8c50710..1d823c7a 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -6,7 +6,6 @@ {% translate "Organize your documents" %} {% endblock title %} {% block workflow_content %} - {% if availability_message %}{% endif %}
{% translate "Organize documents" %}

{% translate "Organize your documents" %}

@@ -22,6 +21,12 @@

{% translate "Organize your documents" %}

{% csrf_token %} +
{{ availability_message }}
{% ui_text "organize_documents.main_document_question" %} {% if documents|length == 1 %} @@ -165,5 +170,6 @@

{% translate "Organize your documents" %}

{{ organize_context|json_script:"organize-context" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/tests/test_filing_availability.py b/efile_app/efile/tests/test_filing_availability.py index 921f143b..3204d4a2 100644 --- a/efile_app/efile/tests/test_filing_availability.py +++ b/efile_app/efile/tests/test_filing_availability.py @@ -158,7 +158,7 @@ def test_outgoing_payload_codes_are_also_checked(configure, client, submission_d from efile.views.session_api import forward_final_filing - configure({"rules": [{"filing_types": ["blocked"]}]}) + configure({"rules": [{"case_types": ["contract"], "filing_types": ["blocked"]}]}) request = RequestFactory().post("/") request.user = submission_draft.user request.session = client.session @@ -201,6 +201,9 @@ def test_existing_case_confirmation_is_blocked_but_search_again_works(configure, submission_draft.docket_number = "2026-CV-123" submission_draft.save() url = reverse("case_confirmation", kwargs={"jurisdiction": "illinois"}) + response = client.get(url) + assert response.context["availability_message"] + assert b'value="yes"' in response.content assert client.post(url, {"confirmed": "yes"}).status_code == 403 assert client.post(url, {"confirmed": "no"}).status_code == 302 @@ -229,3 +232,22 @@ def test_claim_rechecks_availability(configure, submission_draft): _claim_for_submission(submission_draft, {}) submission_draft.refresh_from_db() assert submission_draft.status == FilingDraft.Status.DRAFT + + +@pytest.mark.django_db +def test_live_api_checks_partial_choices_and_multiple_filing_types(configure, client): + configure({"enabled": False, "message": "Court disabled"}) + url = reverse("api:filing_availability") + params = {"jurisdiction": "illinois", "court": "cook:law1"} + response = client.get(url, params) + assert response.json() == {"success": True, "available": False, "message": "Court disabled"} + assert "no-store" in response.headers["Cache-Control"] + configure({"rules": [{"filing_types": ["motion"], "message": "Scheduling unavailable"}]}) + assert client.get(url, params).json()["available"] + params["filing_type"] = ["petition", "motion"] + assert not client.get(url, params).json()["available"] + + +@pytest.mark.django_db +def test_live_api_rejects_unknown_jurisdiction(client): + assert client.get(reverse("api:filing_availability"), {"jurisdiction": "unknown"}).status_code == 400 diff --git a/efile_app/efile/tests/test_security_boundaries.py b/efile_app/efile/tests/test_security_boundaries.py index 32068ea2..45233945 100644 --- a/efile_app/efile/tests/test_security_boundaries.py +++ b/efile_app/efile/tests/test_security_boundaries.py @@ -86,7 +86,9 @@ def test_submission_logs_exclude_secrets_and_contents(caplog, status): patch("efile.views.session_api.describe_efsp_error", return_value="Upstream error"), patch("requests.post", return_value=response), ): - result = forward_final_filing(request, {"efile_data": {"al_court_bundle": "private-payload-marker"}}) + result = forward_final_filing( + request, {"efile_data": {"al_court_bundle": {"elements": [{"filing_type": "private-payload-marker"}]}}} + ) assert result.status_code == (200 if status == 201 else status) assert "Filing submission response status=" in caplog.text for marker in ( diff --git a/efile_app/efile/views/case_confirmation.py b/efile_app/efile/views/case_confirmation.py index 145dcd90..f357985f 100644 --- a/efile_app/efile/views/case_confirmation.py +++ b/efile_app/efile/views/case_confirmation.py @@ -63,6 +63,7 @@ def case_confirmation(request, jurisdiction): "is_logged_in": True, "filing_draft": draft_snapshot(draft), "case": draft, + "availability_message": draft_unavailable_message(draft), } context.update(get_workflow_context(WorkflowStepKey.CASE_CONFIRMATION, jurisdiction, draft)) return render(request, "efile/case_confirmation.html", context) diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 625e39df..0644ddbf 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -168,8 +168,8 @@ def forward_final_filing(request, data): if message := filing_unavailable_message( jurisdiction_id, court_id, - case_category=efile_data.get("efile_case_category", ""), - case_type=efile_data.get("efile_case_type", ""), + case_category=efile_data.get("efile_case_category") or case_data.get("case_category", ""), + case_type=efile_data.get("efile_case_type") or case_data.get("case_type", ""), filing_types=[item.get("filing_type", "") for item in efile_data["al_court_bundle"].get("elements", [])], ): return JsonResponse( diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 6911b5a2..77f0c7b5 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -652,4 +652,128 @@ test('small screens: editing fits without sideways scrolling', async ({ await field(page, 'case_type').edit.click(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(0); +}); +// Availability is checked as a choice changes, before the form is submitted. +test.describe('live filing availability', () => { + const warning = 'Hearing scheduling is not supported for this selection.'; + const choices = [ + ['court', 'court', 'vt:washington', 'cook:cvd1'], + ['case_category', 'case_category', '7000', '6198'], + ['case_type', 'case_type', '183542', '183541'], + ['filing_type', 'filing_type', '143133', '143132'], + ]; + for (const [name, parameter, blocked, allowed] of choices) { + test(`warns immediately for ${name} and clears after correction`, async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await page.route('**/api/filing-availability/**', (route) => { + const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blocked); + return route.fulfill({ + json: { + success: true, + available: !unavailable, + message: unavailable ? warning : '' + } + }); + }); + await openSavedDraft(page); + const notice = page.locator('#filing-availability-notice'); + const next = page.getByRole('button', { + name: 'Confirm and continue' + }); + await expect(notice).toBeHidden(); + await field(page, name).edit.click(); + await field(page, name).select.selectOption(blocked); + await expect(notice).toHaveText(warning); + await expect(next).toBeDisabled(); + await field(page, name).select.selectOption(allowed); + await expect(notice).toBeHidden(); + await expect(next).toBeEnabled(); + }); + } + + test('a late allowed response cannot clear the current restriction', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + let delayAllowed = false; + let lateDelivered = false; + await page.route('**/api/filing-availability/**', async (route) => { + const blocked = new URL(route.request().url()).searchParams.get('filing_type') === '143133'; + const delayed = delayAllowed && !blocked; + if (delayed) await new Promise((resolve) => setTimeout(resolve, 500)); + await route.fulfill({ + json: { + success: true, + available: !blocked, + message: blocked ? warning : '' + } + }).catch(() => {}); + if (delayed) lateDelivered = true; + }); + await openSavedDraft(page); + await expect(page.locator('#filing-availability-notice')).toBeHidden(); + await field(page, 'filing_type').edit.click(); + delayAllowed = true; + const requested = page.waitForRequest('**/api/filing-availability/**'); + await field(page, 'filing_type').select.selectOption('143132'); + await requested; + await field(page, 'filing_type').select.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect.poll(() => lateDelivered).toBe(true); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.getByRole('button', { + name: 'Confirm and continue' + })).toBeDisabled(); + }); + + test('organizing warns on a filing-type change before saving', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await page.route('**/api/dropdowns/document-types/**', (route) => route.fulfill({ + json: { + success: true, + data: [] + } + })); + await page.route('**/api/get-filing-components/**', (route) => route.fulfill({ + json: { + success: true, + data: [] + } + })); + await page.route('**/api/dropdowns/optional-services/**', (route) => route.fulfill({ + json: { + success: true, + data: [] + } + })); + await page.route('**/api/filing-availability/**', (route) => { + const blocked = new URL(route.request().url()).searchParams.getAll('filing_type').includes('143133'); + return route.fulfill({ + json: { + success: true, + available: !blocked, + message: blocked ? warning : '' + } + }); + }); + await page.goto('/jurisdiction/illinois/organize-documents/'); + const choice = page.locator('.filing-type').first(); + await expect(choice).toBeEnabled(); + await choice.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.locator('#save-document-details')).toBeDisabled(); + await choice.selectOption('143132'); + await expect(page.locator('#filing-availability-notice')).toBeHidden(); + await expect(page.locator('#save-document-details')).toBeEnabled(); + }); }); \ No newline at end of file From ea4ea8ffbd1d7b90a3734db18e187ce4b6fa9f3b Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 1 Oct 2026 08:40:48 -0400 Subject: [PATCH 3/5] Match filing restrictions by human-readable names and optional regex --- .../partners-courts/jurisdiction-config.md | 38 +++-- efile_app/efile/api/filing_availability.py | 6 +- .../efile/services/filing_availability.py | 122 +++++++++++++-- .../efile/static/config/states/illinois.yaml | 4 +- .../efile/static/js/extraction-review.js | 6 +- .../efile/static/js/filing-availability.js | 6 +- .../efile/static/js/organize-documents.js | 10 +- .../templates/efile/organize_documents.html | 1 + .../efile/tests/test_filing_availability.py | 148 +++++++++++++++--- efile_app/efile/views/extraction_review.py | 6 +- efile_app/efile/views/organize_documents.py | 2 + efile_app/efile/views/session_api.py | 19 +-- efile_app/tests/confirm-case-editing.spec.js | 50 +++++- 13 files changed, 334 insertions(+), 84 deletions(-) diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index 93a148b2..dfaab3e1 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -413,8 +413,10 @@ court_specific_requirements: ``` To disable only selected types, add `rules`. Each rule can list `case_categories`, -`case_types`, or `filing_types`. Use the court API's **codes**, quoted as strings, -not display names or the semantic keys used by document checklists. Values in a +`case_types`, or `filing_types`. Use the court API's **human-readable names**, +not Tyler numeric IDs or the semantic keys used by document checklists. Strings match the full name exactly +(case-sensitive, with surrounding whitespace removed from the selected name). +Names remain usable when Tyler changes the numeric IDs. Values in a list are alternatives. If a rule has more than one selector, all selectors must match. Separate rules are alternatives. A filing-type restriction checks every document in the envelope, including supporting documents. @@ -428,7 +430,7 @@ court_specific_requirements: "cook:cvd1": filing_availability: rules: - - case_types: ["183541"] # Contract in the local browser fixture; verify against the target API. + - case_types: ["Contract"] message: >- LITEFile cannot file this case type in Cook County yet because it requires scheduling a hearing. Our e-filing service does not support @@ -436,10 +438,24 @@ court_specific_requirements: ``` This is an example, **not an active restriction or a claim that all Contract -filings require scheduling**. Confirm the affected case-type codes and scheduling +filings require scheduling**. Confirm the affected case-type names and scheduling requirements before enabling it. To block only particular filing types within -that case type, add `filing_types: ["verified-filing-type-code"]` to the same rule. -To block a category, use `case_categories: ["verified-category-code"]` instead. +that case type, add `filing_types: ["Motion"]` to the same rule. +To block a category, use `case_categories: ["Small Claims"]` instead. + +For variable names, use an explicit regex entry. Regexes match the **whole name** +using Python `re.fullmatch`; use `.*` for a partial match and `(?i)` for +case-insensitive matching. Plain strings are literal, even if they contain +punctuation such as parentheses. Strings and regex entries can share a list: + +```yaml +rules: + - case_types: ["Contract"] + filing_types: + - "Motion" + - regex: '(?i)motion to .+' + message: "LITEFile does not support hearing scheduling for this filing yet." +``` A `"cook:*"` court entry applies to every court code starting with `cook:`. This prefix form is only for availability rules; existing checklist overrides @@ -458,9 +474,13 @@ court, category, case type, or filing type. Continue is disabled until the check finishes and the selection is allowed. Changing to an allowed selection clears the warning. The same checks run on document filing-type changes and court selection during an existing-case lookup; a restricted case type is shown as -soon as the lookup resolves the case. LITEFile also checks saved drafts on the checklist and -review pages, and rechecks both the saved draft and outgoing codes before -submission. Drafts and documents remain available for correction. Removing the +soon as the lookup resolves the case. LITEFile also checks saved drafts on the +checklist and review pages, and rechecks the saved draft before submission. +At submission, it resolves +the outgoing IDs to current names from the court API and applies the same +name rules; browser-provided labels cannot bypass this check. If required +names cannot be resolved, submission stops with a retry message. Drafts and +documents remain available for correction. Removing the matching restriction restores filing; already-open pages are checked again on the next request. Restart application workers when deploying configuration changes, as with other jurisdiction configuration updates. diff --git a/efile_app/efile/api/filing_availability.py b/efile_app/efile/api/filing_availability.py index f59fa552..3721967a 100644 --- a/efile_app/efile/api/filing_availability.py +++ b/efile_app/efile/api/filing_availability.py @@ -13,9 +13,9 @@ def get_filing_availability(request): message = filing_unavailable_message( request.GET.get("jurisdiction") or request.session.get("jurisdiction"), request.GET.get("court", ""), - case_category=request.GET.get("case_category", ""), - case_type=request.GET.get("case_type", ""), - filing_types=request.GET.getlist("filing_type"), + case_category=request.GET.get("case_category_name", ""), + case_type=request.GET.get("case_type_name", ""), + filing_types=request.GET.getlist("filing_type_name"), ) except InvalidJurisdiction as error: return JsonResponse({"success": False, "error": str(error)}, status=400) diff --git a/efile_app/efile/services/filing_availability.py b/efile_app/efile/services/filing_availability.py index 2d2e7d39..edff9225 100644 --- a/efile_app/efile/services/filing_availability.py +++ b/efile_app/efile/services/filing_availability.py @@ -1,32 +1,56 @@ -"""Deployment-owned restrictions on filing through LITEFile, matched by API codes.""" +"""Deployment-owned restrictions on filing through LITEFile, matched by human-readable type names.""" +import re +from urllib.parse import quote, urlencode + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.shortcuts import render from django.utils.translation import gettext as _ from efile.models import FilingDocument +from efile.services.efsp_payload import _EfspLookups from efile.utils.config_loader import config_loader from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context +def _court_availability(jurisdiction, court): + courts = config_loader.load_jurisdiction_config(jurisdiction).get("court_specific_requirements") or {} + keys = [court] + if ":" in court: + keys.append(court.split(":", 1)[0] + ":*") + return [(courts.get(key) or {}).get("filing_availability") or {} for key in keys] + + +def _matches_name(name, matcher): + """Strings match exactly; explicit regex entries match the entire name.""" + if not name: + return False + if isinstance(matcher, str): + return name == matcher + if isinstance(matcher, dict) and isinstance(matcher.get("regex"), str): + try: + return re.fullmatch(matcher["regex"], name) is not None + except re.error as error: + raise ImproperlyConfigured(f"Invalid filing availability regex: {matcher['regex']!r}") from error + raise ImproperlyConfigured("Availability selectors must contain names or {regex: pattern} entries.") + + def filing_unavailable_message(jurisdiction, court, *, case_category="", case_type="", filing_types=()): """Return a reason, or an empty string when no restriction matches. Exact court settings are checked before a county prefix (``cook:*``). Rules are additive: an exact court cannot enable a county-wide restriction. Values within a selector are alternatives; selectors within a rule must all - match. Names from the browser never participate in an availability decision. + match. Category, case-type, and filing-type values are human-readable names, + never Tyler numeric IDs. Matching is case-sensitive; regex flags are explicit. """ - courts = config_loader.load_jurisdiction_config(jurisdiction).get("court_specific_requirements") or {} - keys = [court] - if ":" in court: - keys.append(court.split(":", 1)[0] + ":*") selections = { - "case_categories": {str(case_category)} - {""}, - "case_types": {str(case_type)} - {""}, - "filing_types": {str(value) for value in filing_types if value}, + "case_categories": {str(case_category or "").strip()} - {""}, + "case_types": {str(case_type or "").strip()} - {""}, + "filing_types": {str(value).strip() for value in filing_types if value}, } - for key in keys: - availability = (courts.get(key) or {}).get("filing_availability") or {} + for availability in _court_availability(jurisdiction, court): fallback = availability.get("message") or _( "LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file." ) @@ -34,7 +58,8 @@ def filing_unavailable_message(jurisdiction, court, *, case_category="", case_ty for rule in availability.get("rules") or []: selectors = [name for name in selections if name in rule] if selectors and all( - selections[name].intersection(str(value) for value in rule[name]) for name in selectors + any(_matches_name(value, matcher) for value in selections[name] for matcher in rule[name]) + for name in selectors ): return rule.get("message") or fallback if availability.get("enabled") is False: @@ -46,9 +71,76 @@ def draft_unavailable_message(draft): return filing_unavailable_message( draft.jurisdiction, draft.court_code, - case_category=draft.case_category_code, - case_type=draft.case_type_code, - filing_types=FilingDocument.objects.filter(draft=draft).values_list("filing_type_code", flat=True), + case_category=draft.case_category_name, + case_type=draft.case_type_name, + filing_types=FilingDocument.objects.filter(draft=draft).values_list("filing_type_name", flat=True), + ) + + +def outgoing_unavailable_message(jurisdiction, court, case_data, payload): + """Resolve outgoing IDs to the court's names; never trust client labels at submit. + + Lookups are only needed for selectors configured for this court. A failed + lookup blocks submission, rather than letting an unknown name evade a rule. + """ + selectors = { + name + for availability in _court_availability(jurisdiction, court) + for rule in availability.get("rules") or [] + for name in ("case_categories", "case_types", "filing_types") + if rule.get(name) + } + if not selectors: + return filing_unavailable_message(jurisdiction, court) + lookups = _EfspLookups() + base = f"{settings.EFSP_URL}/jurisdictions/{quote(jurisdiction, safe='')}/codes/courts/{quote(court, safe=':')}" + category = payload.get("efile_case_category") or case_data.get("case_category", "") + case_type = payload.get("efile_case_type") or case_data.get("case_type", "") + initial = not (payload.get("previous_case_id") or case_data.get("previous_case_id")) + + def resolve(path, codes): + choices = lookups.get(f"{base}/{path}") + names = ( + { + str(item["code"]): item["name"] + for item in choices or [] + if isinstance(item, dict) and "code" in item and isinstance(item.get("name"), str) + } + if isinstance(choices, list) + else {} + ) + if any(not names.get(str(code)) for code in codes): + raise ValueError(_("We could not confirm this filing's availability with the court. Try again later.")) + return [names[str(code)] for code in codes] + + category_name = resolve("categories", [category])[0] if "case_categories" in selectors else "" + type_name = ( + resolve("case_types/?" + urlencode({"category_id": category}), [case_type])[0] + if "case_types" in selectors + else "" + ) + filing_names = [] + if "filing_types" in selectors: + bundles = payload.get("al_court_bundle", []) + if not isinstance(bundles, list) or not all(isinstance(item, dict) for item in bundles): + raise ValueError(_("We could not read the filing types. Reload the review page and try again.")) + filing_names = resolve( + "filing_types/?" + + urlencode( + { + "initial": str(initial).lower(), + "category_id": category, + "type_id": case_type, + } + ), + [item.get("filing_type", "") for item in bundles], + ) + return filing_unavailable_message( + jurisdiction, + court, + case_category=category_name, + case_type=type_name, + filing_types=filing_names, ) diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml index fd81ec28..91f501be 100644 --- a/efile_app/efile/static/config/states/illinois.yaml +++ b/efile_app/efile/static/config/states/illinois.yaml @@ -527,11 +527,11 @@ court_specific_requirements: requirement: always "cook:cvd1": # Cook County Circuit Court - Municipal Civil Division - # Optional availability hook. Leave commented until affected codes and + # Optional availability hook. Leave commented until affected names and # hearing-scheduling requirements are confirmed against the target EFSP. # filing_availability: # rules: - # - case_types: ["183541"] # Contract; illustrative, not a live restriction. + # - case_types: ["Contract"] # Illustrative, not a live restriction. # message: >- # LITEFile cannot file this case type in Cook County yet because it # requires scheduling a hearing. Our e-filing service does not support diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 723668f4..a5020690 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -56,9 +56,9 @@ selection: () => ({ jurisdiction: context.jurisdiction, court: fields.court.select.value, - case_category: fields.case_category.select.value, - case_type: fields.case_type.select.value, - filing_types: [fields.filing_type.select.value], + case_category_name: fields.case_category.select.value ? apiUtils.cleanOptionText(fields.case_category.select.selectedOptions[0]?.textContent) : "", + case_type_name: fields.case_type.select.value ? apiUtils.cleanOptionText(fields.case_type.select.selectedOptions[0]?.textContent) : "", + filing_type_names: [fields.filing_type.select.value ? apiUtils.cleanOptionText(fields.filing_type.select.selectedOptions[0]?.textContent) : ""], }), }); diff --git a/efile_app/efile/static/js/filing-availability.js b/efile_app/efile/static/js/filing-availability.js index 96e01d5e..3488c89e 100644 --- a/efile_app/efile/static/js/filing-availability.js +++ b/efile_app/efile/static/js/filing-availability.js @@ -32,10 +32,10 @@ const params = new URLSearchParams({ jurisdiction: values.jurisdiction, court: values.court, - case_category: values.case_category || "", - case_type: values.case_type || "", + case_category_name: values.case_category_name || "", + case_type_name: values.case_type_name || "", }); - (values.filing_types || []).forEach((value) => params.append("filing_type", value)); + (values.filing_type_names || []).forEach((value) => params.append("filing_type_name", value)); try { const response = await fetch(`/api/filing-availability/?${params}`, { signal: controller.signal diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js index 052568f4..831391b4 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -38,9 +38,13 @@ selection: () => ({ jurisdiction: context.jurisdiction, court: context.court, - case_category: context.case_category, - case_type: context.case_type, - filing_types: cards().map((card) => card.querySelector(".filing-type").value || card.dataset.filingType || ""), + case_category_name: context.case_category_name, + case_type_name: context.case_type_name, + filing_type_names: cards().map((card) => { + const select = card.querySelector(".filing-type"); + if (!select.options.length) return card.dataset.filingTypeName || ""; + return select.value ? apiUtils.cleanOptionText(select.selectedOptions[0]?.textContent) : ""; + }), }), }); availability.check(); diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index 1d823c7a..0be037de 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -61,6 +61,7 @@

{% translate "Organize your documents" %}

data-document-id="{{ document.id }}" data-role="{{ document.role }}" data-filing-type="{{ document.filing_type_code }}" + data-filing-type-name="{{ document.filing_type_name }}" data-document-type="{{ document.document_type_code }}" data-filing-component="{{ document.filing_component_code }}" data-optional-services="{{ document.requested_optional_services|join:',' }}"> diff --git a/efile_app/efile/tests/test_filing_availability.py b/efile_app/efile/tests/test_filing_availability.py index 3204d4a2..f938401d 100644 --- a/efile_app/efile/tests/test_filing_availability.py +++ b/efile_app/efile/tests/test_filing_availability.py @@ -20,7 +20,8 @@ def submission_draft(client, django_user_model): court_code="cook:law1", court_name="Cook County", case_category_code="civil", - case_type_code="contract", + case_category_name="Civil", + case_type_code="Contract", case_type_name="Contract", ) reviewed_document( @@ -29,6 +30,7 @@ def submission_draft(client, django_user_model): sort_order=0, name="Petition.pdf", filing_type_code="petition", + filing_type_name="Petition", document_type_code="public", ) client.force_login(user) @@ -63,22 +65,22 @@ def test_default_enabled_and_jurisdiction_and_court_scope(configure): @pytest.mark.parametrize( "selector,arguments", [ - ("case_categories", {"case_category": "123"}), - ("case_types", {"case_type": "123"}), - ("filing_types", {"filing_types": ["unrestricted", "123"]}), + ("case_categories", {"case_category": "Human name"}), + ("case_types", {"case_type": "Human name"}), + ("filing_types", {"filing_types": ["unrestricted", "Human name"]}), ], ) -def test_each_selector_matches_codes_including_numeric_yaml(configure, selector, arguments): - configure({"rules": [{selector: [123], "message": "Scheduling is unavailable."}]}) +def test_each_selector_matches_exact_names(configure, selector, arguments): + configure({"rules": [{selector: ["Human name"], "message": "Scheduling is unavailable."}]}) assert filing_unavailable_message("illinois", "cook:law1", **arguments) == "Scheduling is unavailable." assert not filing_unavailable_message("illinois", "cook:law1") def test_rules_combine_selectors_and_fall_back_to_court_message(configure): - configure({"message": "Court notice", "rules": [{"case_types": ["contract"], "filing_types": ["motion"]}]}) - assert not filing_unavailable_message("illinois", "cook:law1", case_type="contract") + configure({"message": "Court notice", "rules": [{"case_types": ["Contract"], "filing_types": ["Motion"]}]}) + assert not filing_unavailable_message("illinois", "cook:law1", case_type="Contract") assert ( - filing_unavailable_message("illinois", "cook:law1", case_type="contract", filing_types=["motion"]) + filing_unavailable_message("illinois", "cook:law1", case_type="Contract", filing_types=["Motion"]) == "Court notice" ) @@ -92,14 +94,14 @@ def test_county_prefix_is_bounded_and_specific_message_wins(monkeypatch): "cook:law1": { "filing_availability": { "enabled": True, - "rules": [{"case_types": ["contract"], "message": "Hearing scheduling is unavailable."}], + "rules": [{"case_types": ["Contract"], "message": "Hearing scheduling is unavailable."}], } }, } }, ) assert ( - filing_unavailable_message("illinois", "cook:law1", case_type="contract") + filing_unavailable_message("illinois", "cook:law1", case_type="Contract") == "Hearing scheduling is unavailable." ) assert filing_unavailable_message("illinois", "cook:law1", case_type="other") == "County notice" @@ -110,16 +112,16 @@ def test_county_prefix_is_bounded_and_specific_message_wins(monkeypatch): def test_empty_rules_do_not_disable_and_specific_overrides_generic(configure): configure({"rules": [{"message": "No selector"}, {"case_types": []}]}) assert not filing_unavailable_message("illinois", "cook:law1") - configure({"enabled": False, "message": "Generic", "rules": [{"case_types": ["contract"], "message": "Specific"}]}) - assert filing_unavailable_message("illinois", "cook:law1", case_type="contract") == "Specific" + configure({"enabled": False, "message": "Generic", "rules": [{"case_types": ["Contract"], "message": "Specific"}]}) + assert filing_unavailable_message("illinois", "cook:law1", case_type="Contract") == "Specific" @pytest.mark.django_db def test_supporting_document_blocks_envelope_and_removal_restores_filing(configure, submission_draft): - configure({"rules": [{"filing_types": ["motion"]}]}) + configure({"rules": [{"filing_types": ["Motion"]}]}) assert not draft_unavailable_message(submission_draft) document = FilingDocument.objects.create( - draft=submission_draft, role="supporting", sort_order=1, filing_type_code="motion" + draft=submission_draft, role="supporting", sort_order=1, filing_type_code="123", filing_type_name="Motion" ) assert draft_unavailable_message(submission_draft) document.delete() @@ -129,7 +131,7 @@ def test_supporting_document_blocks_envelope_and_removal_restores_filing(configu @pytest.mark.django_db @pytest.mark.parametrize("view", ["document_checklist", "case_review"]) def test_blocked_page_preserves_draft_and_escapes_message(configure, client, submission_draft, view): - configure({"rules": [{"case_types": ["contract"], "message": "Scheduling "}]}) + configure({"rules": [{"case_types": ["Contract"], "message": "Scheduling "}]}) response = client.get(reverse(view, kwargs={"jurisdiction": "illinois"})) assert response.status_code == 403 assert b"Scheduling <script>" in response.content @@ -141,7 +143,7 @@ def test_blocked_page_preserves_draft_and_escapes_message(configure, client, sub @pytest.mark.django_db def test_direct_submit_cannot_bypass_new_restriction(configure, client, submission_draft, monkeypatch): - configure({"rules": [{"case_types": ["contract"], "message": "Hearing scheduling is unavailable."}]}) + configure({"rules": [{"case_types": ["Contract"], "message": "Hearing scheduling is unavailable."}]}) forward = Mock() monkeypatch.setattr("efile.views.submission.forward_final_filing", forward) response = client.post(reverse("submit_final_filing"), {}, content_type="application/json") @@ -153,20 +155,35 @@ def test_direct_submit_cannot_bypass_new_restriction(configure, client, submissi @pytest.mark.django_db -def test_outgoing_payload_codes_are_also_checked(configure, client, submission_draft, monkeypatch): +def test_outgoing_ids_are_resolved_to_names_before_checking(configure, client, submission_draft, monkeypatch): from django.test import RequestFactory from efile.views.session_api import forward_final_filing - configure({"rules": [{"case_types": ["contract"], "filing_types": ["blocked"]}]}) + configure({"rules": [{"case_types": ["Contract"], "filing_types": ["Blocked filing"]}]}) request = RequestFactory().post("/") request.user = submission_draft.user request.session = client.session external = Mock() monkeypatch.setattr("requests.post", external) + monkeypatch.setattr( + "efile.services.filing_availability._EfspLookups.get", + lambda self, url: ( + [{"code": "Contract", "name": "Contract"}] + if "case_types" in url + else [{"code": "allowed", "name": "Allowed filing"}, {"code": "blocked", "name": "Blocked filing"}] + ), + ) response = forward_final_filing( request, - {"efile_data": {"al_court_bundle": {"elements": [{"filing_type": "allowed"}, {"filing_type": "blocked"}]}}}, + { + "efile_data": { + "al_court_bundle": [ + {"filing_type": "allowed"}, + {"filing_type": "blocked", "filing_description": "Allowed filing"}, + ] + } + }, ) assert response.status_code == 403 external.assert_not_called() @@ -174,7 +191,7 @@ def test_outgoing_payload_codes_are_also_checked(configure, client, submission_d @pytest.mark.django_db def test_confirm_new_case_keeps_choices_editable_when_blocked(configure, client, submission_draft): - configure({"rules": [{"case_types": ["blocked"], "message": "Scheduling is unavailable."}]}) + configure({"rules": [{"case_types": ["Blocked type"], "message": "Scheduling is unavailable."}]}) response = client.post( reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), { @@ -190,7 +207,7 @@ def test_confirm_new_case_keeps_choices_editable_when_blocked(configure, client, assert b"Scheduling is unavailable." in response.content assert response.context["extraction_context"]["case_type_code"] == "blocked" submission_draft.refresh_from_db() - assert submission_draft.case_type_code == "contract" + assert submission_draft.case_type_code == "Contract" @pytest.mark.django_db @@ -210,16 +227,17 @@ def test_existing_case_confirmation_is_blocked_but_search_again_works(configure, @pytest.mark.django_db def test_organize_blocks_supporting_type_but_allows_correction(configure, client, submission_draft): - configure({"rules": [{"filing_types": ["motion"], "message": "Scheduling is unavailable."}]}) + configure({"rules": [{"filing_types": ["Motion"], "message": "Scheduling is unavailable."}]}) document = submission_draft.documents.first() url = reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) - details = {"id": document.pk, "filing_type": "motion", "document_type": "public"} + details = {"id": document.pk, "filing_type": "123", "filing_type_name": "Motion", "document_type": "public"} payload = {"main_document_id": document.pk, "documents": [details]} response = client.post(url, payload, content_type="application/json") assert response.status_code == 403 assert response.json()["error"] == "Scheduling is unavailable." assert b"Scheduling is unavailable." in client.get(url).content details["filing_type"] = "petition" + details["filing_type_name"] = "Petition" assert client.post(url, payload, content_type="application/json").status_code == 200 @@ -242,12 +260,90 @@ def test_live_api_checks_partial_choices_and_multiple_filing_types(configure, cl response = client.get(url, params) assert response.json() == {"success": True, "available": False, "message": "Court disabled"} assert "no-store" in response.headers["Cache-Control"] - configure({"rules": [{"filing_types": ["motion"], "message": "Scheduling unavailable"}]}) + configure({"rules": [{"filing_types": ["Motion"], "message": "Scheduling unavailable"}]}) assert client.get(url, params).json()["available"] - params["filing_type"] = ["petition", "motion"] + params["filing_type_name"] = ["petition", "Motion"] assert not client.get(url, params).json()["available"] @pytest.mark.django_db def test_live_api_rejects_unknown_jurisdiction(client): assert client.get(reverse("api:filing_availability"), {"jurisdiction": "unknown"}).status_code == 400 + + +@pytest.mark.parametrize( + "name,blocked", [("Contract", True), ("contract", False), ("Contract dispute", False), ("183541", False)] +) +def test_exact_name_matching_is_not_substring_or_id_matching(configure, name, blocked): + configure({"rules": [{"case_types": ["Contract"]}]}) + assert bool(filing_unavailable_message("illinois", "cook:law1", case_type=name)) == blocked + + +@pytest.mark.parametrize( + "selector,argument", + [("case_categories", "case_category"), ("case_types", "case_type"), ("filing_types", "filing_types")], +) +def test_regex_fullmatches_names_with_explicit_flags(configure, selector, argument): + configure({"rules": [{selector: [{"regex": "(?i)motion(?: to .+)?"}]}]}) + for name, expected in [("MOTION", True), ("Motion to dismiss", True), ("Notice of Motion", False), ("", False)]: + value = [name] if argument == "filing_types" else name + assert bool(filing_unavailable_message("illinois", "cook:law1", **{argument: value})) == expected + + +@pytest.mark.django_db +def test_saved_draft_rule_survives_numeric_id_changes(configure, submission_draft): + configure({"rules": [{"case_types": ["Contract"]}]}) + for code in ("183541", "999999"): + submission_draft.case_type_code = code + submission_draft.save() + assert draft_unavailable_message(submission_draft) + + +@pytest.mark.parametrize("code", ["183541", "999999"]) +def test_submit_resolves_current_ids_and_ignores_client_names(configure, monkeypatch, code): + from efile.services.filing_availability import outgoing_unavailable_message + + configure({"rules": [{"case_types": ["Contract"]}]}) + monkeypatch.setattr( + "efile.services.filing_availability._EfspLookups.get", lambda self, url: [{"code": code, "name": "Contract"}] + ) + assert outgoing_unavailable_message( + "illinois", + "cook:law1", + {}, + { + "efile_case_type": code, + "case_type_name": "Unrestricted type", + "al_court_bundle": [], + }, + ) + + +def test_submit_blocks_when_names_cannot_be_resolved(configure, monkeypatch): + from efile.services.filing_availability import outgoing_unavailable_message + + configure({"rules": [{"case_types": ["Contract"]}]}) + monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lambda self, url: None) + with pytest.raises(ValueError, match="could not confirm"): + outgoing_unavailable_message("illinois", "cook:law1", {}, {"efile_case_type": "123"}) + + +def test_literal_punctuation_and_invalid_regex(configure): + from django.core.exceptions import ImproperlyConfigured + + configure({"rules": [{"filing_types": ["Motion (Other)"]}]}) + assert filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion (Other)"]) + assert not filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion Other"]) + configure({"rules": [{"case_types": [{"regex": "["}]}]}) + with pytest.raises(ImproperlyConfigured, match="Invalid filing availability regex"): + filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + + +def test_empty_selectors_need_no_code_lookup(configure, monkeypatch): + from efile.services.filing_availability import outgoing_unavailable_message + + configure({"rules": [{"case_types": []}]}) + lookup = Mock() + monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lookup) + assert not outgoing_unavailable_message("illinois", "cook:law1", {}, {}) + lookup.assert_not_called() diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index d561fcea..515bec34 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -143,9 +143,9 @@ def extraction_review(request, jurisdiction): availability_message = filing_unavailable_message( jurisdiction, court_code, - case_category=case_category_code, - case_type=case_type_code, - filing_types=[request.POST.get("filing_type_code", "")], + case_category=request.POST.get("case_category_name", ""), + case_type=request.POST.get("case_type_name", ""), + filing_types=[request.POST.get("filing_type_name", "")], ) offered_roles = {role["id"] for role in _offered_filer_roles(request, jurisdiction)} diff --git a/efile_app/efile/views/organize_documents.py b/efile_app/efile/views/organize_documents.py index 4d1ab4f7..e1e7e469 100644 --- a/efile_app/efile/views/organize_documents.py +++ b/efile_app/efile/views/organize_documents.py @@ -193,6 +193,8 @@ def organize_documents(request, jurisdiction): "court": draft.court_code, "case_category": draft.case_category_code, "case_type": draft.case_type_code, + "case_category_name": draft.case_category_name, + "case_type_name": draft.case_type_name, "existing_case": "yes" if draft.existing_case == ExistingCase.EXISTING else "no", "guessed_filing_type": (draft.extracted_guesses or {}).get("filing type", ""), "default_confidentiality": confidentiality_config.get("default", ""), diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 0644ddbf..11b2cb95 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -9,7 +9,7 @@ from ..services.efsp_errors import describe_efsp_error from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..services.extraction_fields import EXTRACTION_FIELDS, EXTRACTION_HINTS -from ..services.filing_availability import filing_unavailable_message +from ..services.filing_availability import outgoing_unavailable_message from ..services.submission_errors import SubmissionErrorCode from ..utils.case_data_utils import get_case_data, get_upload_data, update_case_data from ..utils.proxy_connection import get_party_type_code_from_api @@ -164,14 +164,15 @@ def forward_final_filing(request, data): status=400, ) - # Check the actual outgoing codes too: the JSON payload is client supplied. - if message := filing_unavailable_message( - jurisdiction_id, - court_id, - case_category=efile_data.get("efile_case_category") or case_data.get("case_category", ""), - case_type=efile_data.get("efile_case_type") or case_data.get("case_type", ""), - filing_types=[item.get("filing_type", "") for item in efile_data["al_court_bundle"].get("elements", [])], - ): + # Resolve the outgoing IDs to authoritative names before applying rules. + try: + message = outgoing_unavailable_message(jurisdiction_id, court_id, case_data, efile_data) + except ValueError as error: + return JsonResponse( + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": str(error)}, + status=412, + ) + if message: return JsonResponse( {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, status=403 ) diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 77f0c7b5..8b5dfb02 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -657,12 +657,12 @@ test('small screens: editing fits without sideways scrolling', async ({ test.describe('live filing availability', () => { const warning = 'Hearing scheduling is not supported for this selection.'; const choices = [ - ['court', 'court', 'vt:washington', 'cook:cvd1'], - ['case_category', 'case_category', '7000', '6198'], - ['case_type', 'case_type', '183542', '183541'], - ['filing_type', 'filing_type', '143133', '143132'], + ['court', 'court', 'vt:washington', 'cook:cvd1', 'vt:washington'], + ['case_category', 'case_category_name', '7000', '6198', 'Civil'], + ['case_type', 'case_type_name', '183542', '183541', 'Tort'], + ['filing_type', 'filing_type_name', '143133', '143132', 'Answer'], ]; - for (const [name, parameter, blocked, allowed] of choices) { + for (const [name, parameter, blocked, allowed, blockedName] of choices) { test(`warns immediately for ${name} and clears after correction`, async ({ page }) => { @@ -670,7 +670,7 @@ test.describe('live filing availability', () => { flat: true }); await page.route('**/api/filing-availability/**', (route) => { - const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blocked); + const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blockedName); return route.fulfill({ json: { success: true, @@ -695,6 +695,40 @@ test.describe('live filing availability', () => { }); } + test('a changed numeric ID still sends the human-readable name', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await page.route('**/api/dropdowns/case-types/**', (route) => route.fulfill({ + json: { + success: true, + data: [{ + value: '987654', + text: 'Contract' + }], + } + })); + await page.route('**/api/filing-availability/**', (route) => { + const blocked = new URL(route.request().url()).searchParams.get('case_type_name') === 'Contract'; + return route.fulfill({ + json: { + success: true, + available: !blocked, + message: blocked ? warning : '' + } + }); + }); + await page.goto(PAGE); + await expect(field(page, 'case_type').select).toBeEnabled(); + await field(page, 'case_type').select.selectOption('987654'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.getByRole('button', { + name: 'Confirm and continue' + })).toBeDisabled(); + }); + test('a late allowed response cannot clear the current restriction', async ({ page }) => { @@ -704,7 +738,7 @@ test.describe('live filing availability', () => { let delayAllowed = false; let lateDelivered = false; await page.route('**/api/filing-availability/**', async (route) => { - const blocked = new URL(route.request().url()).searchParams.get('filing_type') === '143133'; + const blocked = new URL(route.request().url()).searchParams.get('filing_type_name') === 'Answer'; const delayed = delayAllowed && !blocked; if (delayed) await new Promise((resolve) => setTimeout(resolve, 500)); await route.fulfill({ @@ -757,7 +791,7 @@ test.describe('live filing availability', () => { } })); await page.route('**/api/filing-availability/**', (route) => { - const blocked = new URL(route.request().url()).searchParams.getAll('filing_type').includes('143133'); + const blocked = new URL(route.request().url()).searchParams.getAll('filing_type_name').includes('Answer'); return route.fulfill({ json: { success: true, From 4cef609fe0e2021ee97553dc6007b8bdfe211af9 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 1 Oct 2026 08:53:46 -0400 Subject: [PATCH 4/5] Document filing availability configuration and operations in full --- docs/docs/admin/configuration.md | 11 + .../partners-courts/filing-availability.md | 393 ++++++++++++++++++ docs/docs/partners-courts/index.md | 2 +- .../partners-courts/jurisdiction-config.md | 99 +---- docs/sidebars.js | 1 + 5 files changed, 416 insertions(+), 90 deletions(-) create mode 100644 docs/docs/partners-courts/filing-availability.md diff --git a/docs/docs/admin/configuration.md b/docs/docs/admin/configuration.md index a5c5318b..70ecb01b 100644 --- a/docs/docs/admin/configuration.md +++ b/docs/docs/admin/configuration.md @@ -63,6 +63,17 @@ OPENAI_API_KEY="sk-..." ``` +## Court filing availability + +Court restrictions live in the jurisdiction YAML, not in environment variables. +Use `court_specific_requirements..filing_availability` to block a whole +court or selected human-readable category, case-type, and filing-type names. +The default is enabled; exact-name and explicit regex matching are supported. + +The [filing availability guide](../partners-courts/filing-availability.md) covers +the complete schema, examples, immediate filer warnings, server enforcement, +deployment, re-enabling, and troubleshooting. + ## Document preparation and previews Configure Gotenberg 8.16 or newer for Word conversion and PDF form flattening. diff --git a/docs/docs/partners-courts/filing-availability.md b/docs/docs/partners-courts/filing-availability.md new file mode 100644 index 00000000..67c9969b --- /dev/null +++ b/docs/docs/partners-courts/filing-availability.md @@ -0,0 +1,393 @@ +--- +id: filing-availability +title: Control filing availability +sidebar_label: Filing availability +sidebar_position: 3 +--- + +# Control filing availability + +Use court configuration to stop filings that LITEFile cannot support yet—for +example, when a court requires hearing scheduling that the e-filing service +does not provide. Filing is enabled by default. A restriction can cover a whole +court or selected case categories, case types, and filing types. + +Filers see a warning as soon as their selections match a restriction, before +continuing to the remaining questions. The restriction controls filing through +LITEFile; it does not change the court's rules or availability through other +filing providers. + +## Where to configure restrictions + +Edit the jurisdiction's existing YAML file: + +```text +efile_app/efile/static/config/states/.yaml +``` + +Add `filing_availability` inside the relevant court entry under +`court_specific_requirements`. It is a sibling of that court's `case_types`, +`contact`, and other settings. Preserve those existing settings, and merge +examples into the existing entry rather than repeating a YAML key. + +There is no environment variable or database migration for this feature. +`filing_availability` is not a top-level jurisdiction setting. + +### Court keys and type names + +These identify two different things: + +| Value | What to use | +| --- | --- | +| Court entry, such as `"cook:cvd1"` | The court route key used by the e-filing API and the existing court configuration. | +| `case_categories`, `case_types`, `filing_types` | The human-readable names from the court's choice lists, such as `"Small Claims"`, `"Contract"`, or `"Motion"`. | + +Tyler's numeric type IDs can change. Do not use those IDs in availability +selectors. Do not use document-checklist keys such as `name_change` unless +that is also the court's actual displayed name. Use the name shown for the +selected court, without LITEFile's recommendation marker (`*`). Verify names in +the environment where you will deploy: test and production choices can differ. + +The same name may appear in several categories. Combine category and type +selectors when you need to distinguish those cases. A change to a numeric ID +does not require changing a name rule; a change to the human-readable name may. + +## Configuration reference + +All fields are optional. Omit `filing_availability` when no restriction is needed. + +| Field | Type and default | Behavior | +| --- | --- | --- | +| `enabled` | Boolean; enabled unless explicitly `false` | `false` blocks every filing for this court. Use the YAML boolean `false`, not the string `"false"`. `true` does not cancel matching rules. | +| `message` | Plain-text string; built-in message if absent or empty | Default explanation for this court's restrictions. A message alone does not disable filing. | +| `rules` | List; empty by default | Each matching rule blocks filing. Rules are evaluated in list order. | +| `rules[].case_categories` | List of name matchers | Match the selected case category. | +| `rules[].case_types` | List of name matchers | Match the selected case type. | +| `rules[].filing_types` | List of name matchers | Match a filing type on any document in the envelope, including supporting documents. | +| `rules[].message` | Plain-text string; court message if absent or empty | Explanation for this particular restriction. | + +A **name matcher** is either a literal string or a mapping with a `regex` string. +Selectors must be lists, even when there is only one matcher: + +```yaml +case_types: ["Contract"] +filing_types: + - "Motion" + - regex: '(?i)motion to .+' +``` + +These rules apply to both new and existing cases. There is no rule field for +filing phase, date range, or whether an individual filing needs a hearing. Only +configure a combination you intend to block in full; the rule does not determine +hearing requirements automatically. + +### Exact names and regular expressions + +A plain string matches the entire name, case-sensitively. `"Motion"` does not +match `"motion"`, `"Motion to dismiss"`, or `"Notice of Motion"`. Parentheses, +periods, and other punctuation in a plain string are literal. + +LITEFile removes surrounding whitespace from the selected name. It does not +collapse internal spaces, change capitalization, or trim the configured literal. + +A `regex` matcher uses Python `re.fullmatch`, also case-sensitive by default. +Use `(?i)` for case-insensitive matching and `.*` when you intentionally want +to match additional text. Single-quote YAML regexes so backslashes remain literal. + +| Matcher | Matches | Does not match | +| --- | --- | --- | +| `"Motion (Other)"` | `Motion (Other)` | `Motion Other` | +| `{regex: '(?i)motion'}` | `Motion`, `MOTION` | `Motion to dismiss` | +| `{regex: '(?i)motion to .+'}` | `Motion to dismiss` | `Notice of Motion` | +| `{regex: '(?i).*hearing.*'}` | `Notice of hearing` | `Complaint` | + +An invalid regex raises a configuration error when evaluated. It does not +silently become a literal or an allow rule. Check patterns before deployment. + +### Combining selectors + +- Match **any entry** within one selector list. +- Match **every selector present** in one rule. +- Match **any rule** to block filing. +- A missing or not-yet-selected name does not match. A rule with no recognized + selectors, or an empty list for any selector it includes, never matches. + +For example, this rule blocks Motion or Notice of hearing only for Contract +cases in Small Claims: + +```yaml +case_categories: ["Small Claims"] +case_types: ["Contract"] +filing_types: ["Motion", "Notice of hearing"] +``` + +A Complaint in the same case would not match. If its envelope also includes a +supporting document with filing type Motion, the whole envelope is blocked. +To block all Contract cases **or** all Motions, use two separate rules instead. + +## Examples + +All type names below are illustrative. Confirm the affected names and court +requirements before enabling a restriction. + +### Disable a whole court + +The smallest restriction uses the built-in message: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + enabled: false +``` + +To provide a court-specific explanation, add `message`: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + enabled: false + message: >- + LITEFile cannot submit filings to this court yet. Contact the court + clerk to ask how to file. +``` + +### Disable a case type in Cook County + +This example blocks the human-readable case type Contract in Cook County's +Municipal Civil Division and explains the hearing-scheduling limitation: + +```yaml +court_specific_requirements: + "cook:cvd1": + filing_availability: + rules: + - case_types: ["Contract"] + message: >- + LITEFile cannot file this case type in Cook County yet because it + requires scheduling a hearing. Our e-filing service does not support + hearing scheduling yet. Contact the court clerk to ask how to file. +``` + +The example in `states/illinois.yaml` is commented out. It is not an active +restriction or a determination that all Contract filings require scheduling. +If only particular filing types require scheduling, narrow the rule by adding +`filing_types` to the same rule: + +```yaml +rules: + - case_types: ["Contract"] + filing_types: + - "Motion" + - regex: '(?i)motion to .+' + message: "LITEFile does not support hearing scheduling for this filing yet." +``` + +### Disable categories or filing types independently + +Each of these rules is independent. This example blocks every filing in Small +Claims and, in other categories, any envelope containing Notice of hearing: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + message: "LITEFile cannot submit this filing to this court yet." + rules: + - case_categories: ["Small Claims"] + - filing_types: ["Notice of hearing"] + message: >- + This filing needs hearing scheduling, which LITEFile does not + support yet. Contact the court clerk to ask how to file. +``` + +### Apply a restriction across a county + +Use `"cook:*"` to cover every court route key starting with `cook:`: + +```yaml +court_specific_requirements: + "cook:*": + filing_availability: + rules: + - filing_types: ["Notice of hearing"] + message: "LITEFile does not support hearing scheduling for this filing yet." +``` + +The supported prefix is the text before the first colon, followed by `:*`. +`cook:*` matches `cook:cvd1` and `cook:law1`, but not `cook`, `cooksville:law1`, +or an unrelated court. This is not arbitrary wildcard or regex matching. + +Other jurisdictions can use this form if their court route keys share a county +prefix. Otherwise, repeat the rule for each affected court. Court entries are +scoped to their jurisdiction. Prefix matching applies only to availability; +other court-specific features such as checklist overrides still use exact keys. + +## Messages and precedence + +The built-in English message is: + +> LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file. + +Messages are plain text. HTML, Markdown links, and template placeholders are not +rendered or expanded. Custom YAML messages are displayed as written; they do not +use the separate `text` section's translation or Markdown system. Explain the +limitation and give the filer a practical next step without suggesting that they +choose an incorrect court or case type. + +Evaluation follows this order: + +1. Check the exact court entry. Within it, evaluate rules in list order. The + first match supplies its message, falling back to that entry's `message`, + then the built-in message. More detailed selectors do not automatically win; + place more specific explanations first. +2. If no exact-court rule matches and that court has `enabled: false`, block + with its court message or the built-in message. +3. If the exact entry did not block filing, repeat those checks for the county + prefix entry, when one applies. +4. If neither entry blocks filing, allow it. + +A matching rule can therefore supply a specific explanation even when its court +also has `enabled: false`. An exact-court block takes precedence over a prefix +block. An exact court's message does not become the fallback for a prefix rule. + +Restrictions are additive. `enabled: true` and `rules: []` on an exact court do +not exempt it from a county-prefix restriction. There is no allow-rule override. + +## What filers experience + +On the confirmation screen, changing a court, category, case type, or filing type +starts a live availability check. The warning appears beside the changed +selection. Continue is disabled while the check is pending or the selection is +blocked. Changing to an allowed selection clears the warning; late responses +for an older selection cannot replace the current result. + +Court selection during an existing-case lookup uses the same check. Once a case +lookup identifies its category and type, any restriction appears on the case +confirmation screen before the filer confirms it. Changing a document's filing +type on Organize documents also checks availability for the whole envelope. + +If the live check fails, Continue remains disabled and the filer is asked to +reload the page. A failed check is not treated as permission to proceed. + +Server checks also stop blocked choices on form submission, on the checklist +and review pages, and during final submission. Existing drafts are not exempt +from newly added restrictions. Drafts and documents are retained for correction; +removing the restriction lets an otherwise valid draft continue. + +At final submission, LITEFile resolves outgoing numeric IDs to current names +from the court API for configured type selectors and applies the same rules. +It does not trust browser-provided labels as proof that the filing is allowed. +If required names cannot be resolved, it stops submission with a retry message +before sending the filing. A whole-court restriction does not need a name lookup. + +## Deploy, verify, and re-enable + +1. Confirm the affected court route keys and the exact names offered in the + target environment. Decide whether to block the whole court, a category, + a case type, a filing type, or a combination. +2. Add the smallest appropriate restriction to the existing YAML entry. Keep + other court settings intact and write a plain-text explanation when the + built-in message is insufficient. +3. Test a matching selection, an allowed selection, a supporting-document match, + and any relevant county-prefix overlap in development or staging. Confirm + that the warning appears immediately and that correcting the choice clears + it. Test both new- and existing-case paths when they are affected. +4. Deploy the updated YAML through the normal application deployment process + so every application instance receives the same file. +5. Reopen an affected saved draft and verify the restriction. An already-open + browser page is not notified automatically; reload it or change a selection + to refresh the warning. Submission always rechecks availability. + +For an existing jurisdiction file, the configuration loader checks its modified +time and size and reloads changed settings on the next request. Editing its +availability rules does not itself require a worker restart. Adding an entirely +new jurisdiction file requires restarting the application so the loader discovers +it. Follow the normal restart behavior of your deployment system when shipping +files in a new image. + +To re-enable filing, remove the matching rule or the entire availability section. +For a whole-court block, remove `enabled: false` or set it to `true`, and also +remove any matching rules. Check both the exact court and county-prefix entries; +a remaining restriction in either still blocks filing. Verify an allowed result +after deploying the change. Numeric ID changes alone do not require rule edits. + +### Check a configured selection locally + +From `efile_app`, use the same matcher that the application uses. This example +reads the installed Illinois YAML without making a filing or calling the EFSP: + +```bash +uv run python manage.py shell -c 'from efile.services.filing_availability import filing_unavailable_message; print(filing_unavailable_message("illinois", "cook:cvd1", case_category="Small Claims", case_type="Contract", filing_types=["Motion"]))' +``` + +A nonempty result is the blocking message; an empty result means no configured +restriction matched. With the shipped Cook County example still commented out, +this command returns an empty result. This verifies configuration matching, not +the availability of all court services or the validity of a filing. + +Run the feature's regression tests with: + +```bash +uv run pytest -q efile/tests/test_filing_availability.py +``` + +`manage.py check` is not a complete availability-schema or regex validator. +Misspelled selector keys are ignored; a rule containing only unrecognized keys +never matches. Exercise the actual names and regexes before deployment. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| A rule never matches | Confirm the jurisdiction and court route key, YAML nesting, and selector spelling. Use a list of human-readable names, not numeric IDs or checklist keys. Compare capitalization, punctuation, and internal spaces. | +| A regex matches less than expected | Matching covers the whole name. Use `.*` for additional text and `(?i)` if capitalization should not matter. Use single-quoted YAML for patterns containing backslashes. | +| Too many filings are blocked | Multiple rules are alternatives. Put selectors in the same rule to require a combination. Look for `enabled: false`, broad regexes, and county-prefix rules. | +| The wrong explanation appears | First matching rule wins within an entry. Check exact-court precedence and message fallbacks; rules are not automatically ranked by specificity. | +| A court stays blocked after enabling it | `enabled: true` does not cancel its rules or a county-prefix restriction. Remove every applicable restriction and refresh the page. | +| The live check cannot finish | Check the availability endpoint and application logs, then reload. Invalid YAML or regexes need a configuration fix. Do not assume a failed check means the selection is allowed. | +| Submission cannot confirm availability | Verify the EFSP is reachable and the outgoing IDs still appear in its current choice lists. Re-select stale choices if necessary. A failed required name lookup stops submission. | + +## Live-check API and implementation + +The UI uses `GET /api/filing-availability/`. This is a read-only check of supplied +selections, not a reservation or authorization to submit a filing. + +| Query parameter | Meaning | +| --- | --- | +| `jurisdiction` | Installed jurisdiction key. Falls back to the session jurisdiction when omitted. | +| `court` | Court route key. | +| `case_category_name` | Selected human-readable category name, if known. | +| `case_type_name` | Selected human-readable case-type name, if known. | +| `filing_type_name` | Selected human-readable filing-type name. Repeat the parameter for multiple documents. | + +For example, using a local development server: + +```bash +curl --get 'http://127.0.0.1:8000/api/filing-availability/' \ + --data-urlencode 'jurisdiction=illinois' \ + --data-urlencode 'court=cook:cvd1' \ + --data-urlencode 'case_type_name=Contract' \ + --data-urlencode 'filing_type_name=Motion' \ + --data-urlencode 'filing_type_name=Exhibit' +``` + +A successful check returns HTTP 200 even when filing is blocked: + +```json +{"success": true, "available": false, "message": "The configured blocking message."} +``` + +An allowed result has `available: true` and `message: ""`. Responses use +`Cache-Control: no-store`. An unknown jurisdiction returns HTTP 400. Missing +names do not match type selectors, so an allowed result for partial choices +does not guarantee that later choices will be allowed. Browser-provided names +are advisory; the final submission check resolves names independently. + +The shared matcher and final name resolution live in +`efile_app/efile/services/filing_availability.py`. The live endpoint is in +`efile_app/efile/api/filing_availability.py`, and the shared browser controller +is `efile_app/efile/static/js/filing-availability.js`. Regression coverage lives +in `efile_app/efile/tests/test_filing_availability.py` and the live-availability +cases in `efile_app/tests/confirm-case-editing.spec.js`. diff --git a/docs/docs/partners-courts/index.md b/docs/docs/partners-courts/index.md index 6418070b..43b28315 100644 --- a/docs/docs/partners-courts/index.md +++ b/docs/docs/partners-courts/index.md @@ -39,7 +39,7 @@ efile_app/efile/static/config/ ## Guides in this section -1. [**Jurisdiction & court configuration**](./jurisdiction-config.md): Setting up state metadata, logos, court codes, and clerk contact numbers. +1. [**Jurisdiction & court configuration**](./jurisdiction-config.md): Setting up state metadata, logos, court codes, and clerk contact numbers. See [Filing availability](./filing-availability.md) to disable filing for courts or selected category, case-type, and filing-type names, with immediate explanations for filers. 2. [**Document checklists & filing plans**](./document-checklists.md): Authoring plain-language document checklists with requirement levels and role-based conditions. 3. [**Customizing AI extraction & prompts**](./ai-customization.md): Fine-tuning LLM extraction prompts and field dictionaries for court documents. 4. [**Docassemble & AssemblyLine integration**](./interview-integration.md): Connecting automated interview workflows to e-file directly. diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index dfaab3e1..4fbda336 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -395,92 +395,13 @@ request. ## Temporarily disable filing -Filing through LITEFile is enabled unless a rule below matches. Put -`filing_availability` under a court in `court_specific_requirements` in -`efile_app/efile/static/config/states/.yaml`. This controls LITEFile, -not whether the court accepts filings through other providers. - -To disable a whole court, set `enabled: false`. An optional `message` explains -why. Without a message, LITEFile says it cannot submit this filing to this court -right now and suggests contacting the clerk about how to file. - -```yaml -court_specific_requirements: - "court-api-code": - filing_availability: - enabled: false - message: "LITEFile cannot file in this court yet. Contact the clerk about how to file." -``` - -To disable only selected types, add `rules`. Each rule can list `case_categories`, -`case_types`, or `filing_types`. Use the court API's **human-readable names**, -not Tyler numeric IDs or the semantic keys used by document checklists. Strings match the full name exactly -(case-sensitive, with surrounding whitespace removed from the selected name). -Names remain usable when Tyler changes the numeric IDs. Values in a -list are alternatives. If a rule has more than one selector, all selectors must -match. Separate rules are alternatives. A filing-type restriction checks every -document in the envelope, including supporting documents. - -For example, this Cook County configuration demonstrates blocking the Contract -case type in the Municipal Civil Division when hearing scheduling is required -but unsupported by the EFSP: - -```yaml -court_specific_requirements: - "cook:cvd1": - filing_availability: - rules: - - case_types: ["Contract"] - message: >- - LITEFile cannot file this case type in Cook County yet because it - requires scheduling a hearing. Our e-filing service does not support - hearing scheduling yet. Contact the court clerk to ask how to file. -``` - -This is an example, **not an active restriction or a claim that all Contract -filings require scheduling**. Confirm the affected case-type names and scheduling -requirements before enabling it. To block only particular filing types within -that case type, add `filing_types: ["Motion"]` to the same rule. -To block a category, use `case_categories: ["Small Claims"]` instead. - -For variable names, use an explicit regex entry. Regexes match the **whole name** -using Python `re.fullmatch`; use `.*` for a partial match and `(?i)` for -case-insensitive matching. Plain strings are literal, even if they contain -punctuation such as parentheses. Strings and regex entries can share a list: - -```yaml -rules: - - case_types: ["Contract"] - filing_types: - - "Motion" - - regex: '(?i)motion to .+' - message: "LITEFile does not support hearing scheduling for this filing yet." -``` - -A `"cook:*"` court entry applies to every court code starting with `cook:`. -This prefix form is only for availability rules; existing checklist overrides -still use exact court keys. Other jurisdictions may use the same `prefix:*` -form if their court codes share a county prefix. Otherwise, repeat the rule for -each affected court code. Codes are always scoped to their jurisdiction. - -The first matching rule supplies its message, falling back to the court's -`message`, then to the built-in text. Exact court settings are checked before -county-prefix settings. Restrictions are additive: `enabled: true` or an empty -rule list on one court does not cancel a county-prefix restriction. A rule with -no selectors does not match; use `enabled: false` to disable a whole court. - -Filers see a warning beside their selection as soon as they choose a restricted -court, category, case type, or filing type. Continue is disabled until the check -finishes and the selection is allowed. Changing to an allowed selection clears -the warning. The same checks run on document filing-type changes and court -selection during an existing-case lookup; a restricted case type is shown as -soon as the lookup resolves the case. LITEFile also checks saved drafts on the -checklist and review pages, and rechecks the saved draft before submission. -At submission, it resolves -the outgoing IDs to current names from the court API and applies the same -name rules; browser-provided labels cannot bypass this check. If required -names cannot be resolved, submission stops with a retry message. Drafts and -documents remain available for correction. Removing the -matching restriction restores filing; already-open pages are checked again on -the next request. Restart application workers when deploying configuration -changes, as with other jurisdiction configuration updates. +Add `filing_availability` under a court's `court_specific_requirements` entry to +block the whole court or selected categories, case types, and filing types. +Filing is enabled by default. Type selectors match human-readable names exactly +or with explicit regexes, independent of Tyler's numeric type IDs. Filers see +restrictions immediately after choosing an affected court or type. + +See [Control filing availability](./filing-availability.md) for the complete +field reference, Cook County hearing-scheduling example, county-prefix rules, +message precedence, deployment and re-enabling instructions, troubleshooting, +and live-check API. diff --git a/docs/sidebars.js b/docs/sidebars.js index 41bf3b5b..d2c88566 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -25,6 +25,7 @@ const sidebars = { items: [ 'partners-courts/index', 'partners-courts/jurisdiction-config', + 'partners-courts/filing-availability', 'partners-courts/document-checklists', 'partners-courts/ai-customization', 'partners-courts/interview-integration', From 7618ce817b30afc23331a21dc317d6baee0329f5 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 1 Oct 2026 09:56:11 -0400 Subject: [PATCH 5/5] Fix availability guard gaps and simplify the checks Fixes from review: - Extraction review checks once courts load, so a guided selector with no court chosen no longer leaves Continue blocked without a message. - Organize documents uses saved filing-type names until each card's list loads, so the server-rendered warning is not cleared by a blank check. - Rules are validated on every check, whatever names are saved, so a bad regex fails before the draft is claimed rather than leaving it in ERROR. - The guard only re-enables buttons it disabled itself. Simplifications: - The guard coalesces checks made in the same turn and skips unchanged selections; organize documents checks once after all cards load. - Draft checks query documents only when a filing-type rule exists, and submit checks load the court's rules once. - Shared apiUtils.selectedOptionText, a notice template include, a merged submit error response, and tidier tests and browser-spec mocks. Co-Authored-By: Claude Opus 5.5 --- .../efile/services/filing_availability.py | 124 +++++++++++------- efile_app/efile/static/js/api-utils.js | 5 + .../efile/static/js/extraction-review.js | 13 +- .../efile/static/js/filing-availability.js | 54 ++++++-- .../efile/static/js/organize-documents.js | 15 ++- .../efile/templates/efile/case_lookup.html | 7 +- .../filing_availability_notice.html | 6 + .../templates/efile/extraction_review.html | 7 +- .../templates/efile/organize_documents.html | 7 +- .../efile/tests/test_filing_availability.py | 66 ++++++---- efile_app/efile/views/session_api.py | 10 +- efile_app/tests/confirm-case-editing.spec.js | 67 ++++------ 12 files changed, 218 insertions(+), 163 deletions(-) create mode 100644 efile_app/efile/templates/efile/components/filing_availability_notice.html diff --git a/efile_app/efile/services/filing_availability.py b/efile_app/efile/services/filing_availability.py index edff9225..6f66e822 100644 --- a/efile_app/efile/services/filing_availability.py +++ b/efile_app/efile/services/filing_availability.py @@ -13,27 +13,60 @@ from efile.utils.config_loader import config_loader from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context +_SELECTORS = ("case_categories", "case_types", "filing_types") + def _court_availability(jurisdiction, court): courts = config_loader.load_jurisdiction_config(jurisdiction).get("court_specific_requirements") or {} keys = [court] if ":" in court: keys.append(court.split(":", 1)[0] + ":*") - return [(courts.get(key) or {}).get("filing_availability") or {} for key in keys] + availabilities = [(courts.get(key) or {}).get("filing_availability") or {} for key in keys] + for availability in availabilities: + _validate(availability) + return availabilities + + +def _validate(availability): + """Reject a malformed rule on every check, not only when a name reaches it. + + Otherwise a draft with blank saved names passes the early checks and the + rule first fails at submit, after the draft is claimed. + """ + for rule in availability.get("rules") or []: + for name in _SELECTORS: + matchers = rule.get(name) + if matchers is None: + continue + if not isinstance(matchers, list): + raise ImproperlyConfigured(f"Filing availability {name} must be a list of names.") + for matcher in matchers: + if isinstance(matcher, str): + continue + if not (isinstance(matcher, dict) and isinstance(matcher.get("regex"), str)): + raise ImproperlyConfigured("Availability selectors must contain names or {regex: pattern} entries.") + try: + re.compile(matcher["regex"]) + except re.error as error: + raise ImproperlyConfigured(f"Invalid filing availability regex: {matcher['regex']!r}") from error + + +def _selectors(availabilities): + """The selectors this court's rules actually use.""" + return { + name + for availability in availabilities + for rule in availability.get("rules") or [] + for name in _SELECTORS + if rule.get(name) + } def _matches_name(name, matcher): """Strings match exactly; explicit regex entries match the entire name.""" if not name: return False - if isinstance(matcher, str): - return name == matcher - if isinstance(matcher, dict) and isinstance(matcher.get("regex"), str): - try: - return re.fullmatch(matcher["regex"], name) is not None - except re.error as error: - raise ImproperlyConfigured(f"Invalid filing availability regex: {matcher['regex']!r}") from error - raise ImproperlyConfigured("Availability selectors must contain names or {regex: pattern} entries.") + return name == matcher if isinstance(matcher, str) else re.fullmatch(matcher["regex"], name) is not None def filing_unavailable_message(jurisdiction, court, *, case_category="", case_type="", filing_types=()): @@ -45,20 +78,30 @@ def filing_unavailable_message(jurisdiction, court, *, case_category="", case_ty match. Category, case-type, and filing-type values are human-readable names, never Tyler numeric IDs. Matching is case-sensitive; regex flags are explicit. """ + return _unavailable_message( + _court_availability(jurisdiction, court), + case_category=case_category, + case_type=case_type, + filing_types=filing_types, + ) + + +def _unavailable_message(availabilities, *, case_category="", case_type="", filing_types=()): selections = { "case_categories": {str(case_category or "").strip()} - {""}, "case_types": {str(case_type or "").strip()} - {""}, "filing_types": {str(value).strip() for value in filing_types if value}, } - for availability in _court_availability(jurisdiction, court): + for availability in availabilities: fallback = availability.get("message") or _( "LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file." ) # Specific explanations take precedence over the court's generic one. for rule in availability.get("rules") or []: + # An empty selector (``case_types:``) matches nothing, like ``[]``. selectors = [name for name in selections if name in rule] if selectors and all( - any(_matches_name(value, matcher) for value in selections[name] for matcher in rule[name]) + any(_matches_name(value, matcher) for value in selections[name] for matcher in rule[name] or []) for name in selectors ): return rule.get("message") or fallback @@ -68,12 +111,18 @@ def filing_unavailable_message(jurisdiction, court, *, case_category="", case_ty def draft_unavailable_message(draft): - return filing_unavailable_message( - draft.jurisdiction, - draft.court_code, + availabilities = _court_availability(draft.jurisdiction, draft.court_code) + # Most courts have no filing-type rule; skip the document query for them. + filing_types = ( + FilingDocument.objects.filter(draft=draft).values_list("filing_type_name", flat=True) + if "filing_types" in _selectors(availabilities) + else () + ) + return _unavailable_message( + availabilities, case_category=draft.case_category_name, case_type=draft.case_type_name, - filing_types=FilingDocument.objects.filter(draft=draft).values_list("filing_type_name", flat=True), + filing_types=filing_types, ) @@ -83,15 +132,10 @@ def outgoing_unavailable_message(jurisdiction, court, case_data, payload): Lookups are only needed for selectors configured for this court. A failed lookup blocks submission, rather than letting an unknown name evade a rule. """ - selectors = { - name - for availability in _court_availability(jurisdiction, court) - for rule in availability.get("rules") or [] - for name in ("case_categories", "case_types", "filing_types") - if rule.get(name) - } + availabilities = _court_availability(jurisdiction, court) + selectors = _selectors(availabilities) if not selectors: - return filing_unavailable_message(jurisdiction, court) + return _unavailable_message(availabilities) lookups = _EfspLookups() base = f"{settings.EFSP_URL}/jurisdictions/{quote(jurisdiction, safe='')}/codes/courts/{quote(court, safe=':')}" category = payload.get("efile_case_category") or case_data.get("case_category", "") @@ -100,15 +144,13 @@ def outgoing_unavailable_message(jurisdiction, court, case_data, payload): def resolve(path, codes): choices = lookups.get(f"{base}/{path}") - names = ( - { - str(item["code"]): item["name"] - for item in choices or [] - if isinstance(item, dict) and "code" in item and isinstance(item.get("name"), str) - } - if isinstance(choices, list) - else {} - ) + if not isinstance(choices, list): + choices = [] + names = { + str(item["code"]): item["name"] + for item in choices + if isinstance(item, dict) and "code" in item and isinstance(item.get("name"), str) + } if any(not names.get(str(code)) for code in codes): raise ValueError(_("We could not confirm this filing's availability with the court. Try again later.")) return [names[str(code)] for code in codes] @@ -124,20 +166,10 @@ def resolve(path, codes): bundles = payload.get("al_court_bundle", []) if not isinstance(bundles, list) or not all(isinstance(item, dict) for item in bundles): raise ValueError(_("We could not read the filing types. Reload the review page and try again.")) - filing_names = resolve( - "filing_types/?" - + urlencode( - { - "initial": str(initial).lower(), - "category_id": category, - "type_id": case_type, - } - ), - [item.get("filing_type", "") for item in bundles], - ) - return filing_unavailable_message( - jurisdiction, - court, + query = urlencode({"initial": str(initial).lower(), "category_id": category, "type_id": case_type}) + filing_names = resolve(f"filing_types/?{query}", [item.get("filing_type", "") for item in bundles]) + return _unavailable_message( + availabilities, case_category=category_name, case_type=type_name, filing_types=filing_names, diff --git a/efile_app/efile/static/js/api-utils.js b/efile_app/efile/static/js/api-utils.js index 6c74c4b1..fb274abb 100644 --- a/efile_app/efile/static/js/api-utils.js +++ b/efile_app/efile/static/js/api-utils.js @@ -43,6 +43,11 @@ class ApiUtils { return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); } + // The court's name for a select's choice, or "" when nothing is chosen. + selectedOptionText(select) { + return select.value ? this.cleanOptionText(select.selectedOptions[0]?.textContent) : ""; + } + getCache() { try { const cached = localStorage.getItem('apiResponseCache'); diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index a5020690..27813f54 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -56,9 +56,9 @@ selection: () => ({ jurisdiction: context.jurisdiction, court: fields.court.select.value, - case_category_name: fields.case_category.select.value ? apiUtils.cleanOptionText(fields.case_category.select.selectedOptions[0]?.textContent) : "", - case_type_name: fields.case_type.select.value ? apiUtils.cleanOptionText(fields.case_type.select.selectedOptions[0]?.textContent) : "", - filing_type_names: [fields.filing_type.select.value ? apiUtils.cleanOptionText(fields.filing_type.select.selectedOptions[0]?.textContent) : ""], + case_category_name: apiUtils.selectedOptionText(fields.case_category.select), + case_type_name: apiUtils.selectedOptionText(fields.case_type.select), + filing_type_names: [apiUtils.selectedOptionText(fields.filing_type.select)], }), }); @@ -186,13 +186,12 @@ const field = fields[key]; const option = field.select.selectedOptions[0]; const code = field.select.value; - const text = code ? apiUtils.cleanOptionText(option?.textContent) : ""; + const text = apiUtils.selectedOptionText(field.select); field.current = { code, text }; field.nameInput.value = text; - availability.check(); if (field.valueEl && code) { field.valueEl.textContent = text + (option.textContent.trim().endsWith("*") ? " *" : ""); } @@ -724,5 +723,7 @@ if (changePath) changePath.addEventListener("click", () => openPathQuestion("")); if (applySuggestion) applySuggestion.addEventListener("click", () => openPathQuestion(applySuggestion.dataset.value)); - loadCourts(); + // The guard blocks until a check runs. A guided selector with no court + // chosen yet fires no change, so check once the courts are in place. + loadCourts().finally(() => availability.check()); })(); \ No newline at end of file diff --git a/efile_app/efile/static/js/filing-availability.js b/efile_app/efile/static/js/filing-availability.js index 3488c89e..99d86c73 100644 --- a/efile_app/efile/static/js/filing-availability.js +++ b/efile_app/efile/static/js/filing-availability.js @@ -8,36 +8,63 @@ let blocked = true; let controller; const buttons = Array.from(form.querySelectorAll('button[type="submit"]')); + // Only the buttons this guard disabled are its to re-enable; a page + // disables its own while a save or lookup is in flight. + const held = new Set(); function show(message, unavailable) { blocked = unavailable; notice.textContent = message; notice.hidden = !message; buttons.forEach((button) => { - button.disabled = unavailable; + if (unavailable && !button.disabled) { + button.disabled = true; + held.add(button); + } else if (!unavailable && held.has(button)) { + button.disabled = false; + } }); + if (!unavailable) held.clear(); } - async function check(anchor) { - const token = ++generation; - controller?.abort(); - controller = new AbortController(); - if (anchor) anchor.append(notice); + let queued = false; + let pendingAnchor = null; + let lastQuery = null; + + // Pages call this from every step of a cascade. Calls made in the same + // turn collapse into one check, and an unchanged selection is not re-sent. + function check(anchor) { + if (anchor) pendingAnchor = anchor; + if (queued) return; + queued = true; + queueMicrotask(run); + } + + async function run() { + queued = false; + if (pendingAnchor) pendingAnchor.append(notice); + pendingAnchor = null; const values = selection(); - if (!values.court) { - show("", false); - return; - } - show(gettext("Checking filing availability…"), true); const params = new URLSearchParams({ jurisdiction: values.jurisdiction, - court: values.court, + court: values.court || "", case_category_name: values.case_category_name || "", case_type_name: values.case_type_name || "", }); (values.filing_type_names || []).forEach((value) => params.append("filing_type_name", value)); + const query = params.toString(); + if (query === lastQuery) return; + lastQuery = query; + const token = ++generation; + controller?.abort(); + if (!values.court) { + show("", false); + return; + } + controller = new AbortController(); + show(gettext("Checking filing availability…"), true); try { - const response = await fetch(`/api/filing-availability/?${params}`, { + const response = await fetch(`/api/filing-availability/?${query}`, { signal: controller.signal }); const result = await response.json(); @@ -46,6 +73,7 @@ show(result.message, !result.available); } catch (error) { if (token !== generation || error.name === "AbortError") return; + lastQuery = null; show(gettext("We could not check filing availability. Reload this page to try again."), true); } } diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js index 831391b4..a9b7a5f7 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -42,8 +42,9 @@ case_type_name: context.case_type_name, filing_type_names: cards().map((card) => { const select = card.querySelector(".filing-type"); - if (!select.options.length) return card.dataset.filingTypeName || ""; - return select.value ? apiUtils.cleanOptionText(select.selectedOptions[0]?.textContent) : ""; + // Until the court's list loads, the saved name is the selection. + if (!card.dataset.filingTypesLoaded) return card.dataset.filingTypeName || ""; + return apiUtils.selectedOptionText(select); }), }), }); @@ -376,9 +377,8 @@ async function initializeCard(card) { const filingType = card.querySelector(".filing-type"); setOptions(filingType, await loadFilingTypes(), card.dataset.filingType, "Choose a filing type"); - availability.check(); + card.dataset.filingTypesLoaded = "true"; filingType.addEventListener("change", async () => { - card.dataset.filingType = filingType.value; availability.check(filingType.closest(".form-field") || filingType.parentElement); card.dataset.documentType = ""; card.dataset.filingComponent = ""; @@ -504,5 +504,10 @@ } }); - Promise.all(cards().map(initializeCard)).then(updatePositions).catch((error) => showError(error.message)); + Promise.all(cards().map(initializeCard)) + .then(() => { + updatePositions(); + availability.check(); + }) + .catch((error) => showError(error.message)); })(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/case_lookup.html b/efile_app/efile/templates/efile/case_lookup.html index b0e5c205..d0cc3d0c 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -20,12 +20,7 @@

{% translate "Find your court case" %}

{% endif %} {% csrf_token %} - + {% include "efile/components/filing_availability_notice.html" %}
{% translate "Court" %} diff --git a/efile_app/efile/templates/efile/components/filing_availability_notice.html b/efile_app/efile/templates/efile/components/filing_availability_notice.html new file mode 100644 index 00000000..52e30e06 --- /dev/null +++ b/efile_app/efile/templates/efile/components/filing_availability_notice.html @@ -0,0 +1,6 @@ +
{{ availability_message }}
diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index b23e0ff0..b1686352 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -106,12 +106,7 @@

{% translate "Tell us about your case" %}

{% endif %} {% csrf_token %} -
{{ availability_message }}
+ {% include "efile/components/filing_availability_notice.html" %} {% comment %} First, because the rest depends on it: the court lists different diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index 0be037de..ced9c266 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -21,12 +21,7 @@

{% translate "Organize your documents" %}

{% csrf_token %} -
{{ availability_message }}
+ {% include "efile/components/filing_availability_notice.html" %}
{% ui_text "organize_documents.main_document_question" %} {% if documents|length == 1 %} diff --git a/efile_app/efile/tests/test_filing_availability.py b/efile_app/efile/tests/test_filing_availability.py index f938401d..80a4545d 100644 --- a/efile_app/efile/tests/test_filing_availability.py +++ b/efile_app/efile/tests/test_filing_availability.py @@ -1,12 +1,20 @@ from unittest.mock import Mock import pytest +from django.core.exceptions import ImproperlyConfigured +from django.test import RequestFactory from django.urls import reverse from efile.models import FilingDocument, FilingDraft from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY -from efile.services.filing_availability import draft_unavailable_message, filing_unavailable_message +from efile.services.filing_availability import ( + draft_unavailable_message, + filing_unavailable_message, + outgoing_unavailable_message, +) from efile.tests.helpers import reviewed_document +from efile.views.session_api import forward_final_filing +from efile.views.submission import _claim_for_submission @pytest.fixture @@ -156,10 +164,6 @@ def test_direct_submit_cannot_bypass_new_restriction(configure, client, submissi @pytest.mark.django_db def test_outgoing_ids_are_resolved_to_names_before_checking(configure, client, submission_draft, monkeypatch): - from django.test import RequestFactory - - from efile.views.session_api import forward_final_filing - configure({"rules": [{"case_types": ["Contract"], "filing_types": ["Blocked filing"]}]}) request = RequestFactory().post("/") request.user = submission_draft.user @@ -243,8 +247,6 @@ def test_organize_blocks_supporting_type_but_allows_correction(configure, client @pytest.mark.django_db def test_claim_rechecks_availability(configure, submission_draft): - from efile.views.submission import _claim_for_submission - configure({"enabled": False, "message": "Disabled since review"}) with pytest.raises(ValueError, match="Disabled since review"): _claim_for_submission(submission_draft, {}) @@ -283,26 +285,26 @@ def test_exact_name_matching_is_not_substring_or_id_matching(configure, name, bl "selector,argument", [("case_categories", "case_category"), ("case_types", "case_type"), ("filing_types", "filing_types")], ) -def test_regex_fullmatches_names_with_explicit_flags(configure, selector, argument): +@pytest.mark.parametrize( + "name,expected", [("MOTION", True), ("Motion to dismiss", True), ("Notice of Motion", False), ("", False)] +) +def test_regex_fullmatches_names_with_explicit_flags(configure, selector, argument, name, expected): configure({"rules": [{selector: [{"regex": "(?i)motion(?: to .+)?"}]}]}) - for name, expected in [("MOTION", True), ("Motion to dismiss", True), ("Notice of Motion", False), ("", False)]: - value = [name] if argument == "filing_types" else name - assert bool(filing_unavailable_message("illinois", "cook:law1", **{argument: value})) == expected + value = [name] if argument == "filing_types" else name + assert bool(filing_unavailable_message("illinois", "cook:law1", **{argument: value})) == expected @pytest.mark.django_db -def test_saved_draft_rule_survives_numeric_id_changes(configure, submission_draft): +@pytest.mark.parametrize("code", ["183541", "999999"]) +def test_saved_draft_rule_survives_numeric_id_changes(configure, submission_draft, code): configure({"rules": [{"case_types": ["Contract"]}]}) - for code in ("183541", "999999"): - submission_draft.case_type_code = code - submission_draft.save() - assert draft_unavailable_message(submission_draft) + submission_draft.case_type_code = code + submission_draft.save() + assert draft_unavailable_message(submission_draft) @pytest.mark.parametrize("code", ["183541", "999999"]) def test_submit_resolves_current_ids_and_ignores_client_names(configure, monkeypatch, code): - from efile.services.filing_availability import outgoing_unavailable_message - configure({"rules": [{"case_types": ["Contract"]}]}) monkeypatch.setattr( "efile.services.filing_availability._EfspLookups.get", lambda self, url: [{"code": code, "name": "Contract"}] @@ -320,8 +322,6 @@ def test_submit_resolves_current_ids_and_ignores_client_names(configure, monkeyp def test_submit_blocks_when_names_cannot_be_resolved(configure, monkeypatch): - from efile.services.filing_availability import outgoing_unavailable_message - configure({"rules": [{"case_types": ["Contract"]}]}) monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lambda self, url: None) with pytest.raises(ValueError, match="could not confirm"): @@ -329,8 +329,6 @@ def test_submit_blocks_when_names_cannot_be_resolved(configure, monkeypatch): def test_literal_punctuation_and_invalid_regex(configure): - from django.core.exceptions import ImproperlyConfigured - configure({"rules": [{"filing_types": ["Motion (Other)"]}]}) assert filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion (Other)"]) assert not filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion Other"]) @@ -339,11 +337,31 @@ def test_literal_punctuation_and_invalid_regex(configure): filing_unavailable_message("illinois", "cook:law1", case_type="Contract") -def test_empty_selectors_need_no_code_lookup(configure, monkeypatch): - from efile.services.filing_availability import outgoing_unavailable_message +@pytest.mark.parametrize("selector", [[{"regex": "["}], "Contract", [7]]) +def test_malformed_rules_fail_even_with_blank_names(configure, selector): + # Drafts can save blank names; the rule must still fail before the claim. + configure({"rules": [{"case_types": selector}]}) + with pytest.raises(ImproperlyConfigured): + filing_unavailable_message("illinois", "cook:law1") + + +def test_null_selector_matches_nothing(configure): + configure({"rules": [{"case_types": None}]}) + assert not filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + +def test_empty_selectors_need_no_code_lookup(configure, monkeypatch): configure({"rules": [{"case_types": []}]}) lookup = Mock() monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lookup) assert not outgoing_unavailable_message("illinois", "cook:law1", {}, {}) lookup.assert_not_called() + + +@pytest.mark.django_db +def test_draft_check_skips_document_query_without_filing_type_rules( + configure, submission_draft, django_assert_num_queries +): + configure({"rules": [{"case_types": ["Contract"]}]}) + with django_assert_num_queries(0): + assert draft_unavailable_message(submission_draft) diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 11b2cb95..df4c7ebf 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -166,15 +166,13 @@ def forward_final_filing(request, data): # Resolve the outgoing IDs to authoritative names before applying rules. try: - message = outgoing_unavailable_message(jurisdiction_id, court_id, case_data, efile_data) + message, status = outgoing_unavailable_message(jurisdiction_id, court_id, case_data, efile_data), 403 except ValueError as error: - return JsonResponse( - {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": str(error)}, - status=412, - ) + message, status = str(error), 412 if message: return JsonResponse( - {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, status=403 + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, + status=status, ) # Same fixups the fee quote applied, so the filing matches the quote. diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 8b5dfb02..dab1bd7f 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -656,6 +656,17 @@ test('small screens: editing fits without sideways scrolling', async ({ // Availability is checked as a choice changes, before the form is submitted. test.describe('live filing availability', () => { const warning = 'Hearing scheduling is not supported for this selection.'; + // Restrict any selection that sends `blockedName` as `parameter`. + const mockAvailability = (page, parameter, blockedName) => page.route('**/api/filing-availability/**', (route) => { + const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blockedName); + return route.fulfill({ + json: { + success: true, + available: !unavailable, + message: unavailable ? warning : '' + } + }); + }); const choices = [ ['court', 'court', 'vt:washington', 'cook:cvd1', 'vt:washington'], ['case_category', 'case_category_name', '7000', '6198', 'Civil'], @@ -669,16 +680,7 @@ test.describe('live filing availability', () => { await mockCourtLists(page, { flat: true }); - await page.route('**/api/filing-availability/**', (route) => { - const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blockedName); - return route.fulfill({ - json: { - success: true, - available: !unavailable, - message: unavailable ? warning : '' - } - }); - }); + await mockAvailability(page, parameter, blockedName); await openSavedDraft(page); const notice = page.locator('#filing-availability-notice'); const next = page.getByRole('button', { @@ -710,16 +712,7 @@ test.describe('live filing availability', () => { }], } })); - await page.route('**/api/filing-availability/**', (route) => { - const blocked = new URL(route.request().url()).searchParams.get('case_type_name') === 'Contract'; - return route.fulfill({ - json: { - success: true, - available: !blocked, - message: blocked ? warning : '' - } - }); - }); + await mockAvailability(page, 'case_type_name', 'Contract'); await page.goto(PAGE); await expect(field(page, 'case_type').select).toBeEnabled(); await field(page, 'case_type').select.selectOption('987654'); @@ -753,6 +746,9 @@ test.describe('live filing availability', () => { await openSavedDraft(page); await expect(page.locator('#filing-availability-notice')).toBeHidden(); await field(page, 'filing_type').edit.click(); + // Leave the saved choice first: an unchanged selection is not re-checked. + await field(page, 'filing_type').select.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); delayAllowed = true; const requested = page.waitForRequest('**/api/filing-availability/**'); await field(page, 'filing_type').select.selectOption('143132'); @@ -772,34 +768,15 @@ test.describe('live filing availability', () => { await mockCourtLists(page, { flat: true }); - await page.route('**/api/dropdowns/document-types/**', (route) => route.fulfill({ - json: { - success: true, - data: [] - } - })); - await page.route('**/api/get-filing-components/**', (route) => route.fulfill({ - json: { - success: true, - data: [] - } - })); - await page.route('**/api/dropdowns/optional-services/**', (route) => route.fulfill({ - json: { - success: true, - data: [] - } - })); - await page.route('**/api/filing-availability/**', (route) => { - const blocked = new URL(route.request().url()).searchParams.getAll('filing_type_name').includes('Answer'); - return route.fulfill({ + for (const path of ['dropdowns/document-types', 'get-filing-components', 'dropdowns/optional-services']) { + await page.route(`**/api/${path}/**`, (route) => route.fulfill({ json: { success: true, - available: !blocked, - message: blocked ? warning : '' + data: [] } - }); - }); + })); + } + await mockAvailability(page, 'filing_type_name', 'Answer'); await page.goto('/jurisdiction/illinois/organize-documents/'); const choice = page.locator('.filing-type').first(); await expect(choice).toBeEnabled();