From 01fdc8b9ee02af1d25b9a94d626c1bc7f802b8ad Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 7 Oct 2026 20:36:41 -0400 Subject: [PATCH 1/2] Add configurable informational guidance for saved filing choices --- .../partners-courts/jurisdiction-config.md | 60 +++++++ efile_app/efile/checks.py | 17 ++ .../commands/extract_config_text.py | 9 +- efile_app/efile/services/filing_guidance.py | 133 ++++++++++++++ .../efile/partials/filing_guidance.html | 14 ++ .../efile/templates/efile/workflow_base.html | 1 + efile_app/efile/tests/test_filing_guidance.py | 169 ++++++++++++++++++ efile_app/efile/workflow.py | 3 + 8 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 efile_app/efile/services/filing_guidance.py create mode 100644 efile_app/efile/templates/efile/partials/filing_guidance.html create mode 100644 efile_app/efile/tests/test_filing_guidance.py diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index e5868339..08fe3913 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -678,3 +678,63 @@ and [Vermont small claims guidance](https://www.vtcourts.gov/civil/suing-and-bei The guidance configuration is cached per web process. Restart the web service after editing it; no filing catalog rebuild or database migration is required. + +## Contextual filing guidance + +Use `filing_guidance` for informational warnings and help that depend on the +filer's saved choices. These messages never require an acknowledgment or block +navigation or submission. Court requirements accepted on Review remain separate. + +Each message appears above the form on its configured workflow steps. Choose a +step before the relevant task: for example, `upload_documents` for statewide +upload help, `document_checklist` or `organize_documents` for court-specific +document instructions, and `payment` for payment help. Include `review` when the +same information is useful before submission. Court-specific help appears only +after a court is saved; use a later step if court selection happens after upload. + +The following example uses **placeholder codes and copy**, not actual court +instructions. Replace them with current provider codes and partner-reviewed text +and links before deploying: + +```yaml +filing_guidance: + - id: local_document_help + steps: [document_checklist, organize_documents, review] + title: "Preparing documents for this court" + text: "Read this court's document instructions if you need help preparing your filing." + when: + court_codes: ["example:division-a", "example:division-b"] + case_type_codes: ["example-case-type"] + existing_case: ["new"] + resources: + - label: "Court document instructions" + url: "https://court.example.org/filing-help" +``` + +- `id`, `steps`, `title`, and `text` are required. IDs must be unique within the + jurisdiction. Titles and text are plain text; HTML is escaped. +- Omit `when` for statewide help. All configured conditions must match; within + each list, any one value matches. Missing selections do not match a condition. +- Supported conditions are `court_codes`, `case_category_codes`, `case_type_codes`, + `case_subtype_codes`, `filing_type_codes`, and `existing_case` (`new` or + `existing`). Code matching is exact. Quote numeric provider codes in YAML. +- A `filing_type_codes` condition matches any saved document, including supporting + documents. It does not use an AI prediction or an obsolete draft-level default. +- To target a county, division, or district, list its current filing court codes + in `court_codes`. Names, prefixes, and inferred geographic matches are not used. + Maintain this list when provider codes change. +- `resources` is optional. Each resource needs a descriptive `label` and an + absolute HTTPS `url`. Links open in the same tab. +- Supported steps are the active keys in `efile/workflow.py` (`FILING_WORKFLOW`). + Messages appear in configuration order. They refresh on the next rendered page + after choices are saved, including when a draft is resumed. They do not update + while a dropdown has an unsaved selection. + +No local court guidance is enabled by default. Add reviewed messages to the +appropriate state's YAML file. Run `uv run python manage.py check` after editing; +invalid configuration produces `efile.W004` and that jurisdiction's guidance is +omitted. Filing remains available. Use +`uv run python manage.py check --fail-level WARNING` to reject these errors in a +configuration review. Run `uv run python manage.py extract_config_text` and then +`uv run python manage.py makemessages -l es` to include the new copy and link +labels in the existing translation workflow. diff --git a/efile_app/efile/checks.py b/efile_app/efile/checks.py index 44066f83..e35aec66 100644 --- a/efile_app/efile/checks.py +++ b/efile_app/efile/checks.py @@ -98,3 +98,20 @@ def configured_ui_text_keys_are_known(app_configs, **kwargs): ) ) return problems + + +@register() +def configured_filing_guidance_is_valid(app_configs, **kwargs): + """Report invalid informational rules before partners deploy their copy.""" + from efile.services.filing_guidance import guidance_errors + from efile.utils.config_loader import config_loader + + return [ + Warning( + f"{jurisdiction}.yaml: {message}", + hint="Correct filing_guidance; invalid guidance is omitted, and never blocks a filing.", + id="efile.W004", + ) + for jurisdiction in config_loader.get_available_jurisdictions() + for message in guidance_errors(config_loader.load_jurisdiction_config(jurisdiction)) + ] diff --git a/efile_app/efile/management/commands/extract_config_text.py b/efile_app/efile/management/commands/extract_config_text.py index 66eccb4f..5c7bf439 100644 --- a/efile_app/efile/management/commands/extract_config_text.py +++ b/efile_app/efile/management/commands/extract_config_text.py @@ -22,6 +22,7 @@ from django.core.management.base import BaseCommand, CommandError +from efile.services.filing_guidance import guidance_errors, guidance_strings from efile.utils.config_loader import config_loader from efile.utils.ui_text import UI_STRINGS, config_overrides @@ -54,11 +55,17 @@ def _render(): for jurisdiction in config_loader.get_available_jurisdictions(): config = config_loader.load_jurisdiction_config(jurisdiction) or {} overrides = {key: value for key, value in config_overrides(config).items() if key in UI_STRINGS} + errors = guidance_errors(config) + if errors: + raise CommandError(f"{jurisdiction}.yaml: {'; '.join(errors)}") + overrides.update(guidance_strings(config)) if not overrides: continue lines.append(f" # {jurisdiction}.yaml\n") for key, value in sorted(overrides.items()): - description = UI_STRINGS[key].description + description = ( + UI_STRINGS[key].description if key in UI_STRINGS else "Jurisdiction-maintained filing guidance." + ) if description: lines.append(f" # Translators: {description}\n") # Written the way ruff would format it -- double quotes, one argument diff --git a/efile_app/efile/services/filing_guidance.py b/efile_app/efile/services/filing_guidance.py new file mode 100644 index 00000000..27c01137 --- /dev/null +++ b/efile_app/efile/services/filing_guidance.py @@ -0,0 +1,133 @@ +"""Informational, jurisdiction-maintained help matched to saved filing choices.""" + +import logging +from urllib.parse import urlsplit + +from django.utils.translation import pgettext + +from efile.utils.config_loader import config_loader + +logger = logging.getLogger(__name__) + +# Exact provider codes, not names inferred from a document or a court caption. +DRAFT_CONDITIONS = { + "court_codes": "court_code", + "case_category_codes": "case_category_code", + "case_type_codes": "case_type_code", + "case_subtype_codes": "case_subtype_code", + "existing_case": "existing_case", +} +CONDITION_KEYS = {*DRAFT_CONDITIONS, "filing_type_codes"} +RULE_KEYS = {"id", "steps", "title", "text", "when", "resources"} + + +def _strings(value): + return isinstance(value, list) and bool(value) and all(isinstance(item, str) and item.strip() for item in value) + + +def _resource_is_valid(resource): + if not isinstance(resource, dict) or set(resource) != {"label", "url"}: + return False + if not all(isinstance(resource[key], str) and resource[key].strip() for key in ("label", "url")): + return False + try: + url = urlsplit(resource["url"]) + return url.scheme == "https" and bool(url.hostname) and not url.username and not url.password + except ValueError: + return False + + +def guidance_errors(config): + """Validate authoring errors without treating guidance as a filing requirement.""" + from efile.workflow import FILING_WORKFLOW + + rules = config.get("filing_guidance", []) + if not isinstance(rules, list): + return ["filing_guidance must be a list."] + steps = {step.key for step in FILING_WORKFLOW} + errors = [] + identifiers = set() + for index, rule in enumerate(rules): + prefix = f"filing_guidance[{index}]" + if not isinstance(rule, dict): + errors.append(f"{prefix} must be a mapping.") + continue + if set(rule) - RULE_KEYS: + errors.append( + f"{prefix} contains unknown keys: {', '.join(sorted(str(key) for key in set(rule) - RULE_KEYS))}." + ) + for key in ("id", "title", "text"): + if not isinstance(rule.get(key), str) or not rule[key].strip(): + errors.append(f"{prefix}.{key} must be nonempty text.") + identifier = rule.get("id") + if isinstance(identifier, str): + if identifier in identifiers: + errors.append(f"{prefix}.id duplicates {identifier}.") + identifiers.add(identifier) + if not _strings(rule.get("steps")) or not set(rule["steps"]).issubset(steps): + errors.append(f"{prefix}.steps must list active workflow step keys.") + conditions = rule.get("when", {}) + if not isinstance(conditions, dict): + errors.append(f"{prefix}.when must be a mapping.") + else: + for key, values in conditions.items(): + if key not in CONDITION_KEYS or not _strings(values): + errors.append(f"{prefix}.when.{key} must be a supported condition with a nonempty list of strings.") + elif key == "existing_case" and not set(values).issubset({"new", "existing"}): + errors.append(f"{prefix}.when.existing_case accepts only new and existing.") + resources = rule.get("resources", []) + if not isinstance(resources, list) or not all(_resource_is_valid(resource) for resource in resources): + errors.append(f"{prefix}.resources must contain label and absolute HTTPS url pairs.") + return errors + + +def guidance_strings(config): + """Strings shared by runtime translation and the config extraction command.""" + for rule in config.get("filing_guidance", []): + prefix = f"filing_guidance.{rule['id']}" + for field in ("title", "text"): + yield f"{prefix}.{field}", rule[field] + for index, resource in enumerate(rule.get("resources", [])): + yield f"{prefix}.resources.{index}.label", resource["label"] + + +def filing_guidance(draft, step, *, jurisdiction=None): + if draft is None: + return [] + jurisdiction = jurisdiction or draft.jurisdiction + config = config_loader.load_jurisdiction_config(jurisdiction) + errors = guidance_errors(config) + if errors: + # Invalid informational copy must not prevent a filer from proceeding. + # The system check reports it to maintainers before deployment. + logger.warning("Invalid filing guidance for %s: %s", jurisdiction, "; ".join(errors)) + return [] + matches = [] + document_codes = None + for rule in config.get("filing_guidance", []): + if step not in rule["steps"]: + continue + conditions = rule.get("when", {}) + if any( + getattr(draft, field, "") not in conditions[key] + for key, field in DRAFT_CONDITIONS.items() + if key in conditions + ): + continue + if "filing_type_codes" in conditions: + if document_codes is None: + document_codes = set(draft.documents.values_list("filing_type_code", flat=True)) - {""} + if not document_codes.intersection(conditions["filing_type_codes"]): + continue + prefix = f"filing_guidance.{rule['id']}" + matches.append( + { + "title": pgettext(f"{prefix}.title", rule["title"]), + "text": pgettext(f"{prefix}.text", rule["text"]), + "resources": [ + {"label": pgettext(f"{prefix}.resources.{index}.label", resource["label"]), "url": resource["url"]} + for index, resource in enumerate(rule.get("resources", [])) + ], + } + ) + return matches diff --git a/efile_app/efile/templates/efile/partials/filing_guidance.html b/efile_app/efile/templates/efile/partials/filing_guidance.html new file mode 100644 index 00000000..49baeeb1 --- /dev/null +++ b/efile_app/efile/templates/efile/partials/filing_guidance.html @@ -0,0 +1,14 @@ +{% for guidance in filing_guidance %} + +{% endfor %} diff --git a/efile_app/efile/templates/efile/workflow_base.html b/efile_app/efile/templates/efile/workflow_base.html index b70819b4..adc54fd6 100644 --- a/efile_app/efile/templates/efile/workflow_base.html +++ b/efile_app/efile/templates/efile/workflow_base.html @@ -33,6 +33,7 @@ {% if handoff_draft_id %}

{% translate "Return to saved interview answers and corrections" %}

{% endif %} + {% include "efile/partials/filing_guidance.html" %} {% block workflow_content %} {% endblock workflow_content %} diff --git a/efile_app/efile/tests/test_filing_guidance.py b/efile_app/efile/tests/test_filing_guidance.py new file mode 100644 index 00000000..641fad3d --- /dev/null +++ b/efile_app/efile/tests/test_filing_guidance.py @@ -0,0 +1,169 @@ +"""Saved filing choices select informational guidance, never submission rules.""" + +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from django.template.loader import render_to_string +from django.urls import reverse + +from efile.checks import configured_filing_guidance_is_valid +from efile.management.commands.extract_config_text import _render +from efile.models import FilingDraft +from efile.services.filing_guidance import filing_guidance, guidance_errors +from efile.tests.test_document_extractions import authorize +from efile.utils.config_loader import config_loader + +RULE = { + "id": "local_help", + "steps": ["upload_documents", "review"], + "title": "Help with your documents", + "text": "Read the local document instructions if you need help.", + "when": {"court_codes": ["court:a", "court:b"], "case_type_codes": ["civil"]}, + "resources": [{"label": "Document instructions", "url": "https://court.example.org/help"}], +} + + +@pytest.fixture +def config(monkeypatch): + value = {"filing_guidance": [deepcopy(RULE)]} + original = config_loader.load_jurisdiction_config + + def configured(jurisdiction): + return {**original(jurisdiction), **value} if jurisdiction == "illinois" else original(jurisdiction) + + monkeypatch.setattr(config_loader, "load_jurisdiction_config", configured) + return value + + +def draft(**kwargs): + values = dict(jurisdiction="illinois", court_code="court:a", case_type_code="civil", documents=MagicMock()) + values.update(kwargs) + return SimpleNamespace(**values) + + +@pytest.mark.parametrize( + ("values", "step", "matches"), + [ + ({}, "upload_documents", True), + ({"court_code": "court:b"}, "review", True), + ({}, "payment", False), + ({"court_code": "court:a-extra"}, "review", False), + ({"court_code": ""}, "review", False), + ({"case_type_code": ""}, "review", False), + ({"case_type_code": "family"}, "review", False), + ({"jurisdiction": "vermont"}, "review", False), + ], +) +def test_guidance_matches_saved_codes_and_configured_steps(config, values, step, matches): + assert bool(filing_guidance(draft(**values), step)) is matches + + +def test_no_draft_has_no_guidance(config): + assert filing_guidance(None, "upload_documents") == [] + + +def test_unconditional_guidance_needs_no_court_and_keeps_config_order(config): + first = config["filing_guidance"][0] + first.pop("when") + config["filing_guidance"].append({**first, "id": "another_tip", "title": "Another tip"}) + assert [item["title"] for item in filing_guidance(draft(court_code=""), "review")] == [RULE["title"], "Another tip"] + + +def test_filing_types_use_all_current_documents_and_ignore_old_draft_defaults(config): + config["filing_guidance"][0]["when"]["filing_type_codes"] = ["cover"] + filing = draft(filing_type_code="cover") + filing.documents.values_list.return_value = ["petition", "cover"] + assert filing_guidance(filing, "review") + filing.documents.values_list.return_value = ["petition"] + assert filing_guidance(filing, "review") == [] + filing.documents.values_list.assert_called_with("filing_type_code", flat=True) + + +def test_other_saved_metadata_conditions(config): + config["filing_guidance"][0]["when"].update( + case_category_codes=["family"], case_subtype_codes=["minor"], existing_case=["new"] + ) + filing = draft(case_category_code="family", case_subtype_code="minor", existing_case="new") + assert filing_guidance(filing, "review") + filing.existing_case = "existing" + assert not filing_guidance(filing, "review") + + +@pytest.mark.parametrize( + "change", + [ + {"blocking": True}, + {"when": {"court_code": ["court:a"]}}, + {"when": {"court_codes": []}}, + {"when": {"court_codes": [123]}}, + {"when": {"existing_case": ["maybe"]}}, + {"when": []}, + {"steps": ["not_a_step"]}, + {"steps": "review"}, + {"steps": []}, + {"text": ""}, + {"title": None}, + {"resources": [{"label": "Help", "url": "javascript:alert(1)"}]}, + {"resources": [{"label": "Help", "url": "//example.org/help"}]}, + {"resources": [{"label": "Help", "url": "https://user:pass@example.org/help"}]}, + {"resources": [{"label": "Help", "url": "https://[broken"}]}, + {"resources": "https://example.org"}, + ], +) +def test_invalid_guidance_is_reported_and_omitted_without_blocking(config, change): + config["filing_guidance"][0].update(change) + assert guidance_errors(config) + assert filing_guidance(draft(), "review") == [] + problems = configured_filing_guidance_is_valid(None) + assert problems + assert all(problem.id == "efile.W004" for problem in problems) + + +@pytest.mark.parametrize("rules", [None, {}, ["bad"], [RULE, RULE]]) +def test_invalid_lists_and_duplicate_ids(rules): + assert guidance_errors({"filing_guidance": rules}) + + +def test_plain_text_is_escaped_and_resources_are_links(config): + config["filing_guidance"][0]["title"] = '' + html = render_to_string( + "efile/partials/filing_guidance.html", {"filing_guidance": filing_guidance(draft(), "review")} + ) + assert "