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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

# Install system deps (curl for uv installer, build tools only if needed)
# Install system deps (curl for the uv installer; git because MACourts and
# VTCourts are installed straight from GitHub until they are on PyPI)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates \
curl ca-certificates git \
&& rm -rf /var/lib/apt/lists/*

# Install uv
Expand Down
139 changes: 139 additions & 0 deletions docs/docs/partners-courts/jurisdiction-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,142 @@ A misspelled key is ignored, which means the page quietly keeps the default word
### Translation

Strings configured here are translatable along with the rest of the application. `xgettext` cannot read YAML, so `manage.py extract_config_text` restates them in a generated Python file that `makemessages` reads. Each string carries its key as the gettext message context, so one key's Illinois wording and Vermont wording stay separate messages for a translator. See `efile_app/efile/locale/README.md` for the full workflow.

---

## 4. The court question

Every filing needs a court, and the e-filing service answers that question with one flat list: 207 courts in Illinois, 170 in Massachusetts. A single dropdown over that list asks a filer to recognize their court among the ones that happen to sort next to it.

`court_selector` replaces the list with the questions your state's court structure is actually made of. Illinois routes by county, Massachusetts by court department plus a place the filer knows, Vermont by Superior Court unit — and each of them narrows the same live court list. A jurisdiction with no `court_selector` section keeps the flat list.

```yaml
court_selector:
title: "Choose the Vermont court for this filing"
lede: >-
Start with the court on your paperwork. Superior Court filings then need a
division, and most divisions need the court unit the case belongs to.
steps:
- id: level
type: choice
label: "What Vermont court is this filing for?"
options:
- value: superior
label: "Superior Court"
help: "Vermont's trial court. You will choose a division next."
courts:
name_pattern: "Unit$|^Environmental Division$"
- value: supreme
label: "Supreme Court"
courts:
name_pattern: "^Supreme Court$"

- id: unit
type: select
when:
level: [superior]
label: "Which court unit?"
placeholder: "Choose a unit…"
options_from_courts:
match:
name_pattern: " Unit$"
strip: " Unit$"
label: "{name} ({stem} County)"
```

### How the questions run

An answered question folds to a single line with a **Change** link, and once there is a court, every question folds and the screen states the answer. The court field shares a row with the case category and type, so the cascade has to collapse rather than push them down the page.

Steps are asked in order. A step is shown when its `when:` conditions hold and it has something to ask; the filer's answer narrows the pool of courts, and the deepest answer wins, so "Cook County" is replaced by the division under it. When the pool is down to one court, that is the court. When it is down to a handful, the filer chooses among those rather than among two hundred.

| Key | Meaning |
| --- | --- |
| `type` | `choice` (radio cards), `select` (a dropdown), or `location` (a place lookup). |
| `when` | `{level: [trial]}` shows the step only for those answers; `{department: {not: [land]}}` hides it for those. |
| `options` | Written-out answers. Each may carry a `courts:` query and any data a matcher needs. |
| `options_from_courts` | Answers read off the live court list instead — counties, Cook County's divisions, Vermont's units. A step generated this way disappears when it has fewer than two options, which is how only the counties that divide their Circuit Court get asked about divisions. |
| `option_groups` | One option per `members:` entry, all sharing the group's `courts:` query. Illinois asks which county an appeal came from and answers with its appellate district. |
| `alternative_to` | Two questions that name the same court. Both stay on screen; answering either one answers for both. |
| `short_label` | What the question is called once it is answered and folded to one line, e.g. `Division or courthouse`. Defaults to the full `label`, which is usually too long to read well there. |
| `default_by` / `default_hint` | Start a question at an answer, chosen from an earlier one: `{answer: county, values: {Cook: "cook:cvd"}}` starts a Cook County filing at Municipal Civil. A suggested answer never folds away and carries `default_hint` saying it is a suggestion, so it cannot be mistaken for something the filer said. |

### Naming the courts

E-filing services name courts so that they sort. Massachusetts lists a court as `Juvenile Court -- Suffolk County -- Boston`, department first, because that is how the list is organized — but nobody calls it that, and a filer holding paperwork is looking for the Boston Juvenile Court. `court_names` puts the name back:

```yaml
court_names:
- match: '^Juvenile Court -+ (?P<county>.+?) -+ (?P<place>.+)$'
name: "{place} Juvenile Court - {county}"
- match: '^District Court -+ (?P<place>.+)$'
name: "{place} District Court"
```

Each rule is a regular expression with named captures, and `name` is the rewrite. The first rule that matches wins; a name no rule matches is already right, so Vermont's `Addison Unit` and Massachusetts' `Middlesex Probate and Family Court` need no rules at all.

The rewrite happens once, when the court list is read, so everything downstream — the questions, the court finally chosen, and what is saved on the filing — uses the readable name. That includes the `courts:` queries below, so write them against the names as they read, not as the service lists them.

### Court queries

A `courts:` query says which courts an answer leads to. Every rule is optional and they combine with "and":

| Rule | Matches |
| --- | --- |
| `codes` | Exact court codes, in the order given. |
| `code_prefix`, `code_pattern` | The court's code starts with, or matches, this. |
| `name_pattern` | The court's name matches this (case-insensitive). |
| `exclude_code_pattern`, `exclude_name_pattern` | Drop the courts these match. |
| `group` | The court belongs to this group — see `group_by` below. |

Courts that are only a heading over the courts beneath them never reach the questions at all. "Cook County" is such a row: every Cook filing goes to one of the eighty locations whose code hangs off it, and choosing the county itself returns an empty case-category list with nothing to explain why. A court is dropped only when the e-filing service leaves it out of its fileable list **and** other courts hang off its code — Cook County - Chancery fails the second test, has locations under it, and takes filings of its own. A caption that names only such a county still routes: the questions it does settle are filled in, and `default_by` can start the rest somewhere sensible.

Prefer `name_pattern`. Court **names** are stable and readable; Tyler's codes differ from court to court and change without notice. `{value}`, and any earlier step's id, can be used as a placeholder inside a query.

`option_group_pattern` groups a dropdown under headings. It is matched against each court's name (after `strip`), and its named captures say which part is which: `group` is the heading, `label` is what to show under it, and `extra` is anything worth adding only when it is not the heading's own place. Cook County lists more than eighty locations, and reading them as one alphabetical run is what made the old dropdown unusable.

```yaml
option_group_pattern: '^(?P<label>.*?)\s+-\s+(?P<group>District \d+)\b(?:\s+-\s+(?P<extra>.*))?$'
option_group_names:
- "District 1 - Chicago"
- "District 2 - Skokie"
option_group_other: "No courthouse given"
```

`option_group_names` gives each heading its full name and, by the order they are written in, the order they are shown in. That is how the Chicago courts come first in Cook County, where most of its filings go, rather than wherever the alphabet puts them; `option_group_other` heads the courts the pattern found no heading for, and they come last. Without `option_group_names`, headings are taken from the pattern itself and sorted, and a heading over a single court is dropped so that court keeps its full name.

`group_by` on the selector itself is a regular expression whose first capture is the group a court belongs to. Illinois needs one because it names its counties' courts every which way — `Adams County`, `Kankakee - Civil`, `Peoria CR`, `St. Clair County-Backlog` — and they all have to land in one county.

### Asking for a place instead of a county

A `location` step hands what the filer types to a matcher:

```yaml
- id: place
type: location
when:
level: [trial]
department:
not: [land]
label: "Where is the case connected to?"
placeholder: "Town, city, or street address"
button_label: "Find courts"
examples: ["Cambridge", "Somerville", "24 Beacon St, Boston"]
matcher: macourts
court_types_from: department
manual_label: "Or choose your court from the list"
```

`matcher:` names the library that answers the question. `macourts` uses [MACourts](https://github.com/SuffolkLITLab/MACourts), which owns the Massachusetts court records and jurisdiction rules; `vtcourts` uses [VTCourts](https://github.com/SuffolkLITLab/VTCourts), which resolves a Vermont town, county, or ZIP to its Superior Court unit. `court_types_from` names the step whose chosen option carries the `court_types:` the matcher should search, where a matcher needs one. Matches are mapped back onto the e-filing service's own courts — by code where the source carries one, by name otherwise — and a court this environment does not carry is dropped rather than offered.

Massachusetts asks this way because its Trial Court departments do not divide the state the same way: the District Court that serves Somerville is not in the county its Probate and Family Court is, and which Boston Municipal Court division serves an address is a question about which side of a ward line the building is on. A filer knows their town. Asking them for a county produces a confident wrong answer.

The lookup is always an alternative, never a requirement, and there are two ways to say so. Massachusetts sets `manual_label`, which puts the department's full court list beside the lookup for a filer who already knows their court and for a place the rules cannot resolve. Vermont instead marks its lookup `alternative_to: unit`: the unit dropdown stays where it is, and the lookup is the way in for a filer who knows their town but not that the town is in the Washington Unit. Answering either one answers for both.

`no_match_hint` is what the screen says when the lookup finds nothing — worth wording per jurisdiction, since it should point at whichever alternative that state actually offers.

A cross-county ZIP, or two courts with overlapping jurisdiction, come back as a short list to confirm rather than as a guess between them.

:::tip Check your patterns against the live list
The court names a pattern has to match are the ones the e-filing service returns, and they need no authentication to read: `curl "https://efile-test.suffolklitlab.org/jurisdictions/vermont/codes/courts?with_names=true"`.
:::
91 changes: 91 additions & 0 deletions efile_app/efile/api/court_selector_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""The court selector's one endpoint: answers in, the next question out.

The screen holds no court data of its own. Every time the filer answers
something it posts the answers back here and redraws from what comes back, so
the questions, the courts they narrow to, and the court finally chosen are all
decided in one place against the live court list.
"""

import json
import logging

import requests
from django.views.decorators.http import require_http_methods

from efile.services.court_selection import (
build_selection,
derive_answers,
derive_answers_from_guess,
fetch_courts,
)
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

from .base import APIResponseMixin

logger = logging.getLogger(__name__)

# Enough for the deepest configured cascade several times over, and small enough
# that a malformed query cannot turn into work.
MAX_ANSWERS = 20


def _answers(request):
raw = request.GET.get("answers", "")
if not raw:
return {}
try:
parsed = json.loads(raw)
except ValueError:
return {}
if not isinstance(parsed, dict):
return {}
return {str(key): str(value) for key, value in list(parsed.items())[:MAX_ANSWERS] if value not in (None, "")}


@require_http_methods(["GET"])
def get_court_selector(request):
"""The questions to ask now, the courts they lead to, and the court chosen.

``available: false`` means this jurisdiction has no configured selector, and
the caller should fall back to the flat court list it used before.
"""

jurisdiction = get_jurisdiction_from_request(request)
if not jurisdiction:
return APIResponseMixin.error_response("Missing required jurisdiction parameter")

answers = _answers(request)
saved_court = request.GET.get("court", "")
guessed_court = request.GET.get("guessed_court", "")
from_document: list[str] = []

try:
courts = fetch_courts(jurisdiction)
except (requests.RequestException, ValueError) as error:
logger.warning("Could not load the court list for %s: %s", jurisdiction, error)
return APIResponseMixin.error_response("We could not load the list of courts. Try again in a moment.")

if not answers:
# A saved court is only worth working backwards from while it is still a
# court this service offers. One that is not -- a court retired since the
# draft was saved, or a county heading that turned out to take no filings
# -- would otherwise wipe out everything the document said, and leave the
# filer starting from nothing.
known = saved_court and any(court["value"] == saved_court for court in courts)
if known:
answers = derive_answers(jurisdiction, saved_court, courts)
answers["court"] = saved_court
elif guessed_court:
# The document named a court. Whatever its words actually settle is
# filled in and labelled as coming from the document; the rest stays
# for the filer to answer.
answers = derive_answers_from_guess(jurisdiction, guessed_court, courts)
from_document = [key for key in answers if key != "court"]

selection = build_selection(jurisdiction, answers, courts)
if selection is None:
return APIResponseMixin.success_response({"available": False})

for step in selection["steps"]:
step["from_document"] = step["id"] in from_document and bool(step["answer"])
return APIResponseMixin.success_response({"available": True, **selection})
24 changes: 6 additions & 18 deletions efile_app/efile/api/dropdown_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.conf import settings
from django.views.decorators.http import require_http_methods

from efile.services.court_selection import is_non_filing_court
from efile.services.efsp_payload import parse_optional_services
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

Expand Down Expand Up @@ -285,24 +286,11 @@ def get_courts(request):
if isinstance(api_data, list):
for court in api_data:
if isinstance(court, dict) and "code" in court and "name" in court:
# Filter out courts with unwanted patterns in the name
court_name_standardized = court["name"].lower()
if any(
pattern in court_name_standardized
for pattern in [
"(zodyssey)",
"z -",
"zz",
"zdev",
"courtview test",
"rsi test",
"do not use",
"not used",
"file & serve",
"system",
]
):
continue # Skip this court
# Rows that exist only inside Tyler -- test
# fixtures, retired locations -- are not courts
# anyone can file into.
if is_non_filing_court(court["name"]):
continue

courts.append({"value": court["code"], "text": court["name"]})

Expand Down
2 changes: 2 additions & 0 deletions efile_app/efile/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from .case_type_config import get_case_type_config
from .config_views import get_filer_roles, get_form_config
from .court_selector_views import get_court_selector
from .dropdown_views import (
get_case_categories,
get_case_types,
Expand Down Expand Up @@ -45,6 +46,7 @@
path("dropdowns/case-types/", get_case_types, name="case_types"),
path("dropdowns/filing-types/", get_filing_types, name="filing_types"),
path("dropdowns/courts/", get_courts, name="courts"),
path("dropdowns/court-selector/", get_court_selector, name="court_selector"),
path("dropdowns/document-types/", get_document_types, name="document_types"),
path("dropdowns/optional-services/", get_optional_services, name="optional_services"),
path("dropdowns/party-types/", get_party_types, name="party_types"),
Expand Down
Loading
Loading