From cc72b1ce91baedfb058098c847de6402910c50b4 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sun, 16 Aug 2026 08:01:19 -0400 Subject: [PATCH 1/5] Let partners configure a document checklist per case type Closes #100. The check-documents screen used to tell filers, in so many words, that it could not help: "This list cannot tell you which legal forms your case needs." Partners can now say what a case like this usually needs, in the same jurisdiction YAML that already drives the forms. Configuration matches on names, never on Tyler's numeric codes. The codes differ per court and change without notice -- the research doc's Cook County name change codes are already stale against the live proxy, while the names are not -- so partner config names things the way the court's own lists do, and the codes stay where they belong, fetched live for the filing itself. Matching normalizes case, spacing, and dash style (Cook County spells one dissolution case type with a dash and its pair with a hyphen), then compares exactly. Nothing is fuzzy-matched: a court rename is fixed by adding an alias, not by hoping a heuristic catches it. Guidance is advisory. "always", "usually", and "sometimes" set which group an item appears in; none of them blocks a submission. A checklist resolves case type first, then case category as a fallback, and the two are never merged -- a specific list replaces a broad one. Court overrides deep merge into the item dictionary, so cook:cd1 adds its County Division cover sheet, cook:dr1 raises the financial affidavit to "always", and either could drop an inherited item with "include: false", without restating the list. Items can be tied to the lead filing type by name, which is how a minor name change asks for the child's consent and an eviction shows the tenant an appearance instead of a complaint. The filer's progress lives on a new FilingPlan -- their matter -- not on the draft. A plan holds the semantic names, a snapshot of the checklist, and a complete flag per item, so: - editing partner YAML later does not rewrite a checklist someone is already working through; - ticking off "I have the birth certificate" survives the filing it was started with; - one filer can keep several plans, including two of the same case type; - another filing from a plan resolves the stored names against today's code lists rather than trusting yesterday's numbers. draft.document_checklist_acknowledged still means what it meant -- this envelope is ready -- and is kept separate from plan progress. Checked against the live proxy across the eleven Illinois courts in the research doc: every configured name matches a name the courts publish, all nine name change courts and both dissolution namings resolve, and 545 of 2239 live case types now get guidance. Case types nobody configured are unchanged, disclaimer and all. Co-Authored-By: Claude Opus 5 --- .../efile/migrations/0011_filing_plans.py | 67 ++++ efile_app/efile/models.py | 56 ++++ .../efile/services/document_checklists.py | 188 +++++++++++ efile_app/efile/services/filing_plans.py | 295 ++++++++++++++++ efile_app/efile/static/config/README.md | 128 ++++++- .../efile/static/config/base-case-types.yaml | 43 +++ .../efile/static/config/states/illinois.yaml | 284 +++++++++++++++- .../efile/static/css/reorganized-flow.css | 100 ++++++ .../efile/templates/efile/confirmation.html | 9 + .../templates/efile/document_checklist.html | 46 ++- .../efile/tests/test_document_checklists.py | 301 +++++++++++++++++ efile_app/efile/tests/test_document_prep.py | 72 ++++ efile_app/efile/tests/test_filing_plans.py | 316 ++++++++++++++++++ efile_app/efile/urls.py | 7 +- efile_app/efile/utils/config_loader.py | 41 +++ efile_app/efile/views/document_checklist.py | 12 + efile_app/efile/views/draft_views.py | 29 +- 17 files changed, 1984 insertions(+), 10 deletions(-) create mode 100644 efile_app/efile/migrations/0011_filing_plans.py create mode 100644 efile_app/efile/services/document_checklists.py create mode 100644 efile_app/efile/services/filing_plans.py create mode 100644 efile_app/efile/tests/test_document_checklists.py create mode 100644 efile_app/efile/tests/test_filing_plans.py diff --git a/efile_app/efile/migrations/0011_filing_plans.py b/efile_app/efile/migrations/0011_filing_plans.py new file mode 100644 index 0000000..74d5fb7 --- /dev/null +++ b/efile_app/efile/migrations/0011_filing_plans.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.5 on 2026-08-16 11:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("efile", "0010_merge_amount_in_controversy_and_payment_migrations"), + ] + + operations = [ + migrations.CreateModel( + name="FilingPlan", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("jurisdiction", models.CharField(db_index=True, max_length=40)), + ("court_code", models.CharField(blank=True, max_length=100)), + ("court_name", models.CharField(blank=True, max_length=255)), + ("case_category_name", models.CharField(blank=True, max_length=255)), + ("case_type_name", models.CharField(blank=True, max_length=255)), + ("lead_filing_type_name", models.CharField(blank=True, max_length=255)), + ("checklist", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="filing_plans", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + }, + ), + migrations.AddField( + model_name="filingdraft", + name="plan", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="filing_drafts", + to="efile.filingplan", + ), + ), + migrations.AddIndex( + model_name="filingplan", + index=models.Index( + fields=["user", "jurisdiction"], name="plan_user_jurisdiction_idx" + ), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 7ae2422..987d958 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -32,6 +32,53 @@ class Meta: verbose_name_plural = "User Profiles" +class FilingPlan(models.Model): + """A filer's long-lived matter: the documents they are gathering for it. + + A plan outlives any one envelope. It stores what the filer's case *is* in + semantic terms -- the court, case category, case type, and lead filing type + by name -- and never the court's numeric codes for them. Those codes belong + to a filing: they differ per court and change without notice, so a later + filing resolves the stored names against the live code lists instead of + trusting a code saved months ago. + + ``checklist`` is a snapshot of the configured guidance, taken when the plan + is created, plus the filer's own progress. Snapshotting means a partner + editing the YAML later does not silently rewrite a checklist someone is + already working through. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="filing_plans", + ) + title = models.CharField(max_length=255) + jurisdiction = models.CharField(max_length=40, db_index=True) + + court_code = models.CharField(max_length=100, blank=True) + court_name = models.CharField(max_length=255, blank=True) + case_category_name = models.CharField(max_length=255, blank=True) + case_type_name = models.CharField(max_length=255, blank=True) + lead_filing_type_name = models.CharField(max_length=255, blank=True) + + # {item_id: {"label": str, "requirement": "always|usually|sometimes", + # "description": str (optional), "complete": bool}} + checklist = models.JSONField(default=dict, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + indexes = [ + models.Index(fields=["user", "jurisdiction"], name="plan_user_jurisdiction_idx"), + ] + + def __str__(self): + return self.title or f"Filing plan #{self.pk}" + + class FilingDraft(models.Model): """Durable aggregate for a single in-progress or submitted court filing.""" @@ -47,6 +94,15 @@ class Status(models.TextChoices): on_delete=models.CASCADE, related_name="filing_drafts", ) + # The matter this filing belongs to, when the filer has one. A plan can + # gather several filings over time; losing the plan must not lose the filing. + plan = models.ForeignKey( + "FilingPlan", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="filing_drafts", + ) jurisdiction = models.CharField(max_length=40, db_index=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True) current_step = models.CharField( diff --git a/efile_app/efile/services/document_checklists.py b/efile_app/efile/services/document_checklists.py new file mode 100644 index 0000000..325efdd --- /dev/null +++ b/efile_app/efile/services/document_checklists.py @@ -0,0 +1,188 @@ +"""Resolve the partner-configured document checklist for a filing. + +Partners describe, in the jurisdiction YAML, which documents a filer typically +needs for a kind of case. This module turns that configuration into a plain list +of guidance items. + +Two rules shape the whole module: + +* Configuration identifies a case category, case type, or filing type by the + **name** the court's e-filing service returns, never by Tyler's numeric code. + Those codes differ from court to court and change without notice; the names are + stable, and a partner can read them. Codes are still fetched live and used for + the actual filing -- they simply never appear in partner configuration. +* Matching is deterministic. Names are normalized (case, spacing, dashes) and + then compared exactly. Nothing is guessed: when a court renames something, a + partner adds the new name to ``matches.names`` and the checklist works again. + +Requirement levels (``always``, ``usually``, ``sometimes``) are advice for the +filer. Nothing here blocks a submission. +""" + +from __future__ import annotations + +import logging +import re +import unicodedata +from typing import Any + +from efile.utils.config_loader import config_loader + +logger = logging.getLogger(__name__) + +# Strongest first. Also the display order of the checklist. +REQUIREMENT_ORDER: tuple[str, ...] = ("always", "usually", "sometimes") +DEFAULT_REQUIREMENT = "sometimes" + +REQUIREMENT_LABELS: dict[str, str] = { + "always": "Always needed", + "usually": "Usually needed", + "sometimes": "Sometimes needed", +} + +# Courts write the same name with a hyphen, an en dash, or an em dash -- Cook +# County's own case type list uses two different dashes for the same pair of case +# types. Treat them as one character rather than asking partners to guess. +_DASHES = re.compile("[\u2010-\u2015\u2212]") +_WHITESPACE = re.compile(r"\s+") + + +def normalize_name(value: Any) -> str: + """Fold a court-supplied name into its comparable form.""" + + text = unicodedata.normalize("NFKC", str(value or "")) + text = _DASHES.sub("-", text) + text = _WHITESPACE.sub(" ", text) + return text.strip().lower() + + +def _configured_names(entry: dict[str, Any]) -> list[str]: + matches = entry.get("matches") or {} + if not isinstance(matches, dict): + return [] + names: list[str] = [] + for key in ("names", "aliases"): + values = matches.get(key) or [] + if isinstance(values, str): + values = [values] + names.extend(str(value) for value in values) + return names + + +def _find_match(entries: dict[str, Any], name: str) -> tuple[str, dict[str, Any]] | None: + """Find the single configured entry whose names include ``name``.""" + + wanted = normalize_name(name) + if not wanted: + return None + + matched = [ + (key, entry) + for key, entry in entries.items() + if isinstance(entry, dict) and wanted in {normalize_name(value) for value in _configured_names(entry)} + ] + if not matched: + return None + if len(matched) > 1: + # Two entries claiming one court name is a configuration mistake. Say so + # loudly and stay deterministic by keeping the first one in file order. + logger.warning( + "Document checklist config matches %r more than once: %s", + name, + ", ".join(key for key, _entry in matched), + ) + return matched[0] + + +def _applies_to_lead(item: dict[str, Any], lead_filing_type_name: str) -> bool: + """Check an item's optional filing-type condition against the lead document.""" + + condition = item.get("when") or {} + if not isinstance(condition, dict): + return True + wanted = condition.get("lead_filing_type_names") + if not wanted: + return True + if isinstance(wanted, str): + wanted = [wanted] + return normalize_name(lead_filing_type_name) in {normalize_name(value) for value in wanted} + + +def _requirement(item: dict[str, Any], item_id: str) -> str: + requirement = normalize_name(item.get("requirement") or DEFAULT_REQUIREMENT) + if requirement not in REQUIREMENT_ORDER: + logger.warning( + "Checklist item %r has unknown requirement %r; treating it as %r", + item_id, + item.get("requirement"), + DEFAULT_REQUIREMENT, + ) + return DEFAULT_REQUIREMENT + return requirement + + +def _checklist_items(entry: dict[str, Any], lead_filing_type_name: str) -> dict[str, dict[str, Any]]: + documents = entry.get("documents") or {} + if not isinstance(documents, dict): + logger.warning("Checklist config has a non-dictionary documents block; ignoring it") + return {} + + items: list[tuple[str, dict[str, Any]]] = [] + for item_id, raw in documents.items(): + if not isinstance(raw, dict): + logger.warning("Checklist item %r is not a mapping; ignoring it", item_id) + continue + # A court override removes an inherited item with "include: false". + if raw.get("include") is False: + continue + if not _applies_to_lead(raw, lead_filing_type_name): + continue + + item: dict[str, Any] = { + "label": str(raw.get("label") or item_id.replace("_", " ").capitalize()), + "requirement": _requirement(raw, item_id), + } + if raw.get("description"): + item["description"] = str(raw["description"]) + if raw.get("role"): + item["role"] = str(raw["role"]) + items.append((item_id, item)) + + # Strongest guidance first, configuration order within a level. + items.sort(key=lambda pair: REQUIREMENT_ORDER.index(pair[1]["requirement"])) + return dict(items) + + +def resolve_document_checklist( + jurisdiction: str, + court_code: str = "", + case_category_name: str = "", + case_type_name: str = "", + lead_filing_type_name: str = "", +) -> dict[str, dict[str, Any]]: + """Return the configured checklist for one filing, or an empty dict. + + A checklist configured for the case type wins. If no case type matches, broad + case category guidance is used instead. The two are never merged: a specific + list replaces a general one. + + The result holds semantic data only -- our own item IDs, labels, requirement + levels, and optional descriptions. No court codes leak into it. + """ + + if not jurisdiction: + return {} + + sections = config_loader.get_document_checklist_config(jurisdiction, court=court_code or None) + + for section_name, name in (("case_types", case_type_name), ("case_categories", case_category_name)): + match = _find_match(sections.get(section_name) or {}, name) + if match is None: + continue + key, entry = match + if not entry.get("documents"): + continue + logger.debug("Document checklist for %r resolved to %s.%s", name, section_name, key) + return _checklist_items(entry, lead_filing_type_name) + + return {} diff --git a/efile_app/efile/services/filing_plans.py b/efile_app/efile/services/filing_plans.py new file mode 100644 index 0000000..d68568f --- /dev/null +++ b/efile_app/efile/services/filing_plans.py @@ -0,0 +1,295 @@ +"""Create and maintain a filer's FilingPlan -- the matter behind their filings. + +A plan is the filer's own list of documents for a matter ("my name change"), +kept across however many envelopes that matter takes. Two ideas matter here: + +* The plan stores names, not court codes. When the filer starts another filing + from the plan, the stored names are resolved against the court's *current* + code lists, so a plan made six months ago still works after the court renumbers + everything. +* The checklist is snapshotted into the plan when it is created. Later edits to + partner YAML change what new plans get, and leave existing plans alone. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import requests +from django.conf import settings +from django.db import transaction + +from efile.models import FilingDocument, FilingDraft, FilingPlan +from efile.services.document_checklists import ( + REQUIREMENT_LABELS, + REQUIREMENT_ORDER, + normalize_name, + resolve_document_checklist, +) +from efile.services.drafts import create_draft +from efile.workflow import WorkflowStepKey + +logger = logging.getLogger(__name__) + +DEFAULT_PLAN_TITLE = "My filing" + + +def lead_filing_type_name(draft: FilingDraft) -> str: + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + return (lead.filing_type_name if lead else "") or "" + + +def plan_title_for(draft: FilingDraft) -> str: + """Name a new plan after the case it gathers documents for.""" + + for candidate in (draft.case_type_name, draft.case_category_name, draft.case_title): + if candidate: + return candidate[:255] + return DEFAULT_PLAN_TITLE + + +def checklist_snapshot( + checklist: dict[str, dict[str, Any]], + previous: dict[str, Any] | None = None, +) -> dict[str, dict[str, Any]]: + """Copy resolved guidance into plan shape, keeping any progress already made.""" + + previous = previous or {} + snapshot: dict[str, dict[str, Any]] = {} + for item_id, item in checklist.items(): + was_complete = bool((previous.get(item_id) or {}).get("complete")) + snapshot[item_id] = {**item, "complete": was_complete} + return snapshot + + +def resolve_checklist_for_draft(draft: FilingDraft) -> dict[str, dict[str, Any]]: + return resolve_document_checklist( + jurisdiction=draft.jurisdiction, + court_code=draft.court_code, + case_category_name=draft.case_category_name, + case_type_name=draft.case_type_name, + lead_filing_type_name=lead_filing_type_name(draft), + ) + + +def _plan_case(plan: FilingPlan) -> tuple[str, str, str, str]: + return (plan.court_code, plan.case_category_name, plan.case_type_name, plan.lead_filing_type_name) + + +def _draft_case(draft: FilingDraft) -> tuple[str, str, str, str]: + return ( + draft.court_code, + draft.case_category_name, + draft.case_type_name, + lead_filing_type_name(draft), + ) + + +def _detach(draft: FilingDraft) -> None: + draft.plan = None + draft.save(update_fields=["plan", "updated_at"]) + + +def _refresh_plan(plan: FilingPlan, draft: FilingDraft) -> FilingPlan | None: + """Move a plan onto the case its only filing has become. + + Someone who goes back and picks a different case type is not gathering + documents for the old one any more. Progress on items that survive the + change is kept; guidance for a case they are no longer filing is not. + """ + + checklist = resolve_checklist_for_draft(draft) + if not checklist: + _detach(draft) + return None + + auto_title = plan.case_type_name or plan.case_category_name or DEFAULT_PLAN_TITLE + if plan.title == auto_title: + plan.title = plan_title_for(draft) + plan.court_code = draft.court_code + plan.court_name = draft.court_name + plan.case_category_name = draft.case_category_name + plan.case_type_name = draft.case_type_name + plan.lead_filing_type_name = lead_filing_type_name(draft) + plan.checklist = checklist_snapshot(checklist, plan.checklist) + plan.save() + return plan + + +@transaction.atomic +def ensure_plan_for_draft(draft: FilingDraft) -> FilingPlan | None: + """Attach a plan to this draft, creating one from the configured checklist. + + Returns ``None`` when no partner checklist covers this case, so an + unconfigured case type leaves the filer's experience exactly as it was. + """ + + plan = draft.plan + if plan is not None: + if _plan_case(plan) == _draft_case(draft): + return plan + if not FilingDraft.objects.filter(plan=plan).exclude(pk=draft.pk).exists(): + return _refresh_plan(plan, draft) + # The matter has other filings behind it, so it keeps the case it was + # made for. This filing has become a different one and needs its own plan. + _detach(draft) + + checklist = resolve_checklist_for_draft(draft) + if not checklist: + return None + + plan = FilingPlan.objects.create( + user=draft.user, + title=plan_title_for(draft), + jurisdiction=draft.jurisdiction, + court_code=draft.court_code, + court_name=draft.court_name, + case_category_name=draft.case_category_name, + case_type_name=draft.case_type_name, + lead_filing_type_name=lead_filing_type_name(draft), + checklist=checklist_snapshot(checklist), + ) + draft.plan = plan + draft.save(update_fields=["plan", "updated_at"]) + return plan + + +def set_checklist_progress(plan: FilingPlan, completed_ids) -> FilingPlan: + """Record which items the filer says they have gathered. + + Only items already in the plan can be ticked: the checkboxes come from the + plan's own snapshot, so anything else in the POST is not ours. + """ + + completed = {str(item_id) for item_id in completed_ids} + checklist = dict(plan.checklist or {}) + for item_id, item in checklist.items(): + if isinstance(item, dict): + item["complete"] = item_id in completed + plan.checklist = checklist + plan.save(update_fields=["checklist", "updated_at"]) + return plan + + +def grouped_checklist(plan: FilingPlan | None) -> list[dict[str, Any]]: + """Shape a plan's checklist for the page: one group per requirement level.""" + + if plan is None: + return [] + + groups: dict[str, list[dict[str, Any]]] = {level: [] for level in REQUIREMENT_ORDER} + for item_id, item in (plan.checklist or {}).items(): + if not isinstance(item, dict): + continue + requirement = item.get("requirement", "") + if requirement not in groups: + continue + groups[requirement].append( + { + "id": item_id, + "label": item.get("label") or item_id, + "description": item.get("description", ""), + "complete": bool(item.get("complete")), + } + ) + + return [ + {"requirement": level, "label": REQUIREMENT_LABELS[level], "items": items} + for level, items in groups.items() + if items + ] + + +# --- Starting another filing from a saved plan ------------------------------ +# +# The plan remembers names. Tyler remembers codes, and changes them. Everything +# below turns the first into the second, at the moment of filing. + + +def _codes(jurisdiction: str, path: str, **params: Any) -> list[dict[str, Any]]: + url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/{path}" + try: + response = requests.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError): + logger.warning("Could not load %s for jurisdiction %s", url, jurisdiction) + return [] + return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] + + +def _code_for_name(options: list[dict[str, Any]], name: str) -> str: + wanted = normalize_name(name) + if not wanted: + return "" + for option in options: + if normalize_name(option.get("name")) == wanted: + return str(option.get("code") or "") + return "" + + +def resolve_plan_case_codes(plan: FilingPlan) -> dict[str, str]: + """Look up today's codes for the names a plan saved. + + Anything the court no longer publishes under that name comes back empty, and + the filer picks it again on the confirm-filing step -- which is the honest + outcome, and much better than filing against a stale code. + """ + + codes = {"case_category_code": "", "case_type_code": "", "lead_filing_type_code": ""} + if not plan.court_code: + return codes + + categories = _codes( + plan.jurisdiction, + f"{plan.court_code}/categories", + fileable_only=True, + timing="Initial", + ) + codes["case_category_code"] = _code_for_name(categories, plan.case_category_name) + if not codes["case_category_code"]: + return codes + + case_types = _codes( + plan.jurisdiction, + f"{plan.court_code}/case_types/", + category_id=codes["case_category_code"], + timing="Initial", + ) + codes["case_type_code"] = _code_for_name(case_types, plan.case_type_name) + if not codes["case_type_code"] or not plan.lead_filing_type_name: + return codes + + filing_types = _codes( + plan.jurisdiction, + f"{plan.court_code}/filing_types/", + initial="true", + category_id=codes["case_category_code"], + type_id=codes["case_type_code"], + ) + codes["lead_filing_type_code"] = _code_for_name(filing_types, plan.lead_filing_type_name) + return codes + + +@transaction.atomic +def create_draft_from_plan(user, plan: FilingPlan) -> FilingDraft: + """Start another filing in an existing matter, using today's court codes.""" + + codes = resolve_plan_case_codes(plan) + draft = create_draft( + user=user, + jurisdiction=plan.jurisdiction, + current_step=WorkflowStepKey.UPLOAD_DOCUMENTS, + ) + draft.plan = plan + # Whether this one opens a new case or joins the case the plan's first filing + # started is the filer's answer to give, on the confirm-filing step. + draft.court_code = plan.court_code + draft.court_name = plan.court_name + draft.case_category_name = plan.case_category_name + draft.case_category_code = codes["case_category_code"] + draft.case_type_name = plan.case_type_name + draft.case_type_code = codes["case_type_code"] + draft.save() + return draft diff --git a/efile_app/efile/static/config/README.md b/efile_app/efile/static/config/README.md index b0503bd..e4d3627 100644 --- a/efile_app/efile/static/config/README.md +++ b/efile_app/efile/static/config/README.md @@ -11,7 +11,8 @@ This document explains how the Illinois eFile system uses YAML-based configurati 5. [Javascript integration](#javascript-integration) 6. [Adding new case types](#adding-new-case-types) 7. [Court-specific customizations](#court-specific-customizations) -8. [Examples](#examples) +8. [Document checklists](#document-checklists) +9. [Examples](#examples) ## System overview @@ -299,6 +300,131 @@ court_specific_requirements: - **Bond Court (bond)**: Both sections are hidden, and "Required Parties" header is automatically hidden - **Other courts**: Petitioner shows by default, Name Sought hidden by default (unless configured otherwise) +## Document checklists + +A checklist tells the filer which documents a case like theirs usually needs. It +is guidance shown on the "Check your documents" screen, not validation: nothing +in a checklist blocks a submission. + +### Names, never codes + +Checklist configuration identifies a case category, case type, or filing type by +the **name** the court's e-filing service returns. Tyler's numeric codes are +still fetched live and used for the actual filing, but they never appear here: +each court numbers the same concept differently, and the numbers change without +notice. When a court renames something, add the new name — nothing else changes. + +```yaml +case_types: + name_change: + extends: "base_case_types.name_change" + matches: + names: + - "Name Change" # Cook County, County Division + - "Change of Name" # every other circuit checked + aliases: + - "Petition - Change of Name" + documents: + petition: + label: "Request for name change" + requirement: always + role: lead + publication_notice: + label: "Proof that a newspaper published your notice" + requirement: usually + description: "A newspaper must run the notice once a week for three weeks." +``` + +### Requirement levels + +`requirement` is one of three values, and it sets the group the item appears in: + +| Value | Shown as | Means | +| --- | --- | --- | +| `always` | Always needed | The case does not go anywhere without it | +| `usually` | Usually needed | Standard for this kind of case; some cases skip it | +| `sometimes` | Sometimes needed | Only when particular facts apply | + +An unknown value is logged and treated as `sometimes`. + +### Matching rules + +Matching is deterministic, never fuzzy. Names are normalized first — case, +runs of whitespace, and the difference between a hyphen, an en dash, and an em +dash — and then compared exactly. Cook County spells one dissolution case type +with a dash and its pair with a hyphen, so that normalizing matters; anything +beyond it does not, and guessing at legal guidance is not worth the risk. + +Resolution order: + +1. the case type whose `matches` include the court's case type name; +2. otherwise the case category whose `matches` include the case category name; +3. otherwise no checklist at all. + +A case type checklist **replaces** category guidance. The two are never merged. + +### Guidance that depends on the lead document + +Some items only make sense for one kind of lead filing. Add a `when` condition +naming the filing types, again by name: + +```yaml + minor_consent: + label: "Written consent from the child" + requirement: sometimes + when: + lead_filing_type_names: + - "Request for Name Change (Minor Children)" +``` + +The item appears only when the lead document's filing type name matches. If the +lead filing type is not known yet, conditional items stay hidden. + +### Court-specific checklists + +`documents` is a dictionary keyed by your own IDs, and court overrides deep +merge into it, so a court can change one item, add a local form, or drop an +inherited item without restating the list: + +```yaml +court_specific_requirements: + "cook:cd1": + case_types: + name_change: + documents: + publication_notice: + requirement: always # change one field of an inherited item + county_division_cover_sheet: + label: "County Division information sheet" + requirement: always # add a local form + fee_waiver: + include: false # drop an inherited item +``` + +### Category-level guidance + +Broad guidance for cases whose case type nobody has configured yet: + +```yaml +case_categories: + small_claims: + matches: + names: + - "Small Claims" + documents: + supporting_records: + label: "Papers that back up your side" + requirement: usually +``` + +### What the filer sees + +The resolved checklist is copied into the filer's `FilingPlan` — their matter — +the first time they reach the checklist screen, along with a `complete` flag per +item that they tick themselves. Because it is a snapshot, editing this YAML +later changes what **new** plans get and leaves plans people are already working +through alone. + ## Examples ### Name change configuration diff --git a/efile_app/efile/static/config/base-case-types.yaml b/efile_app/efile/static/config/base-case-types.yaml index 324f547..fa7910f 100644 --- a/efile_app/efile/static/config/base-case-types.yaml +++ b/efile_app/efile/static/config/base-case-types.yaml @@ -86,6 +86,31 @@ base_case_types: name_change: keywords: ["name change", "name petition", "change of name"] description: "Legal name change proceedings" + # Names the court's own e-filing lists use for this concept. Matching is on + # names, never on Tyler's numeric codes: the codes differ per court and + # change without notice, while the names are stable and readable. Add an + # alias here when a court starts calling the same thing something new. + matches: + names: + - "Name Change" + - "Change of Name" + - "Petition for Change of Name" + - "Name Change Petition" + # Document guidance shown to the filer. Keys are ours; requirement is one of + # always, usually, or sometimes. This is advice, not validation -- nothing + # here blocks a filing. + documents: + petition: + label: "Request to change a name" + requirement: always + role: lead + proposed_order: + label: "Proposed order for the judge to sign" + requirement: usually + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + description: "File this if you cannot afford the filing fee." sections: parties: title: "Required parties" @@ -119,6 +144,24 @@ base_case_types: divorce: keywords: ["divorce", "dissolution of marriage", "annulment", "dissolution"] description: "Legal dissolution of marriage proceedings" + matches: + names: + - "Dissolution of Marriage" + - "Petition for Dissolution of Marriage" + - "Divorce" + documents: + petition: + label: "Petition to end a marriage" + requirement: always + role: lead + summons: + label: "Summons for the other spouse" + requirement: usually + description: "You do not need this if the other spouse signs an appearance or a waiver." + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + description: "File this if you cannot afford the filing fee." sections: parties: title: "Required parties" diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml index 4746eca..7fe1108 100644 --- a/efile_app/efile/static/config/states/illinois.yaml +++ b/efile_app/efile/static/config/states/illinois.yaml @@ -24,8 +24,57 @@ case_types: name_change: # Inherit from base but add Illinois-specific requirements extends: "base_case_types.name_change" - - # Illinois-specific field modifications (this will be handled by court-specific logic) + + # Case type names Illinois courts publish for a name change. "Name Change" + # is Cook County's County Division; every other circuit checked calls it + # "Change of Name". These replace the base list rather than adding to it. + matches: + names: + - "Name Change" + - "Change of Name" + + # 735 ILCS 5/21-101 and following. Requirement levels are guidance for the + # filer, not court validation. + documents: + petition: + label: "Request for name change" + requirement: always + role: lead + description: "Your name now, the name you want, and how long you have lived in Illinois." + proposed_order: + label: "Proposed order for the judge to sign" + requirement: always + notice_of_court_date: + label: "Notice of your court date" + requirement: usually + publication_notice: + label: "Proof that a newspaper published your notice" + requirement: usually + description: "A newspaper must run the notice once a week for three weeks, unless the judge excuses you." + motion_to_waive_publication: + label: "Motion to skip the newspaper notice" + requirement: sometimes + description: "Ask for this if publishing your name would put you in danger." + minor_consent: + # Only meaningful when the lead document is the minor-name-change form, + # so it is tied to the filing type names Illinois courts use for it. + label: "Written consent from the child" + requirement: sometimes + description: "A child who is 14 or older has to agree to the new name." + when: + lead_filing_type_names: + - "Request for Name Change (Minor Children)" + - "Request for Name Change (Child Information)" + - "Request for Name Change (Additional Children)" + notice_to_other_parent: + label: "Notice for the other parent" + requirement: sometimes + when: + lead_filing_type_names: + - "Request for Name Change (Minor Children)" + - "Request for Name Change (Child Information)" + - "Request for Name Change (Additional Children)" + - "Request for Name Change (Additional Parent)" # Illinois-specific validation rules validation_rules: @@ -39,7 +88,64 @@ case_types: divorce: extends: "base_case_types.divorce" - + + # Cook County's Domestic Relations Division names the case type after the + # petition; the other circuits use the "Dissolution (with children)" family. + # Matching ignores case, extra spaces, and the difference between a hyphen + # and a dash, so one entry covers every spelling of the same name. + matches: + names: + - "Petition for Dissolution of Marriage - Children" + - "Petition for Dissolution of Marriage - No Children" + - "Petition for Dissolution (Civil Union) - Children" + - "Petition for Dissolution (Civil Union) - No Children" + - "Joint Petition For Simplified Dissolution" + - "Dissolution (with children)" + - "Dissolution (without children)" + - "Dissolution of Civil Union (with children)" + - "Dissolution of Civil Union (without children)" + - "Domestic Violence Dissolution (with Children)" + - "Domestic Violence Dissolution (without Children)" + - "Domestic Violence Dissolution of Civil Union (with Children)" + - "Domestic Violence Dissolution of Civil Union (without Children)" + + # 750 ILCS 5/401 and following, plus Illinois Supreme Court Rules 138 and 298. + documents: + petition: + label: "Petition to end your marriage" + requirement: always + role: lead + summons: + label: "Summons for your spouse" + requirement: usually + description: "You do not need this if your spouse files an appearance or signs a waiver." + financial_affidavit: + label: "Financial affidavit" + requirement: usually + description: "The court needs this to decide support, maintenance, or who pays the fees." + certificate_of_dissolution: + label: "Certificate of dissolution of marriage" + requirement: usually + description: "The state health department form the judge needs before signing the judgment." + proof_of_service: + label: "Proof that your spouse got a copy" + requirement: usually + parenting_plan: + label: "Parenting plan" + requirement: sometimes + description: "File this if you have children under 18." + marital_settlement_agreement: + label: "Marital settlement agreement" + requirement: sometimes + description: "File this if you and your spouse already agree on money and property." + proposed_judgment: + label: "Proposed judgment for the judge to sign" + requirement: sometimes + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + description: "File this if you cannot afford the filing fee." + # Illinois-specific additions sections: parties: @@ -71,11 +177,159 @@ case_types: # show_when_field: "has_children" # show_when_value: true + # Eviction is checklist-only: it has no dynamic form sections yet, so it does + # not extend a base case type. + eviction: + description: "Residential eviction cases, from either side" + matches: + names: + # Cook County Municipal Civil Division + - "Eviction - Possession - Residential Complaint Filed - Non-Jury" + - "Eviction - Possession - Residential Complaint Filed - Jury" + - "Eviction - Joint Action - Residential Complaint Filed - Non-Jury" + - "Eviction - Joint Action - Residential Complaint Filed - Jury" + - "Eviction - Non-ERP Case - Residential - Possession Only" + - "Eviction - Non-ERP Case - Residential - Joint Action" + - "CHA Eviction - Non-Jury" + - "CHA Eviction - Jury" + # Circuits outside Cook County + - "Residential - Eviction" + - "Residential - Eviction Possession Only" + - "Eviction - Residential - Eviction" + - "Eviction - Residential - Eviction Possession Only" + + # 735 ILCS 5/9-101 and following. The landlord and the tenant file different + # documents in the same case type, so the lead filing type name decides which + # of the two lead documents is shown. + documents: + complaint: + label: "Eviction complaint" + requirement: always + role: lead + when: + lead_filing_type_names: + - "Complaint" + - "Eviction Complaint" + - "Complaint / Petition - Eviction - Residential - Possession Only - Fee" + - "Complaint / Petition - Eviction - Residential - Possession Only (Govn't) - Fee" + appearance: + label: "Appearance form" + requirement: always + description: "This tells the court you are in the case, so the judge does not rule without you." + when: + lead_filing_type_names: + - "Appearance" + - "Appearance Filed" + - "Appearance (No Fee)" + - "Appearance Filed - Fee" + - "Appearance Filed - Eviction - Possession Only" + - "Answer" + - "Answer Filed" + answer: + label: "Answer to the complaint" + requirement: usually + description: "Your response to what the landlord says, and any defenses you have." + when: + lead_filing_type_names: + - "Appearance" + - "Appearance Filed" + - "Appearance (No Fee)" + - "Appearance Filed - Fee" + - "Appearance Filed - Eviction - Possession Only" + - "Answer" + - "Answer Filed" + fee_waiver: + label: "Request to waive court fees" + requirement: usually + description: "File this if you cannot afford the fee to answer." + proof_of_service: + label: "Proof that the other side got a copy" + requirement: usually + lease: + label: "Your lease" + requirement: sometimes + landlord_notice: + label: "The written notice your landlord gave you" + requirement: sometimes + description: "For example, a 5, 10, or 30 day notice." + jury_demand: + label: "Request for a jury" + requirement: sometimes + description: "Ask for this by your first court date if you want a jury." + +# Broad guidance for a case category, used when no case type matches. A case +# type checklist replaces this list; the two are never merged. +case_categories: + domestic_relations: + matches: + names: + - "Domestic Relations - General Proceedings" + - "Domestic Relations - Parentage/Child Support" + - "Dissolution (Divorce) with Children" + - "Dissolution (Divorce) without Children" + - "Family" + documents: + financial_information: + label: "Your financial information" + requirement: usually + description: "Pay stubs, tax returns, and a list of what you own and owe." + proof_of_service: + label: "Proof that the other side got a copy" + requirement: usually + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + + eviction: + matches: + names: + - "Eviction" + - "Housing" + documents: + lease: + label: "Your lease" + requirement: usually + landlord_notice: + label: "The written notice your landlord gave you" + requirement: usually + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + + small_claims: + matches: + names: + - "Small Claims" + documents: + supporting_records: + label: "Papers that back up your side" + requirement: usually + description: "For example, a contract, bills, receipts, or letters." + proof_of_service: + label: "Proof that the other side got a copy" + requirement: usually + fee_waiver: + label: "Request to waive court fees" + requirement: sometimes + # Illinois-specific court mappings court_specific_requirements: "cook:cd1": # Cook County Circuit Court - County Division case_types: name_change: + # Checklist overrides are merged item by item into the case type above, + # so a court can change one entry or add its own local form without + # repeating the rest of the list. Source: Cook County County Division + # general administrative orders, not the e-filing API. + documents: + county_division_cover_sheet: + label: "County Division information sheet" + requirement: always + description: "Cook County asks for this cover sheet with every name change." + criminal_history_statement: + label: "Statement about your criminal history" + requirement: usually + # Cook County specific requirements - both sections show field_modifications: - field_group: "Petitioner" @@ -87,7 +341,29 @@ court_specific_requirements: conditional_requirements: required_for_courts: ["cook:cd1"] - "bond": # Bond Court + "cook:dr1": # Cook County Circuit Court - Domestic Relations Division + case_types: + divorce: + documents: + domestic_relations_cover_sheet: + label: "Domestic Relations Division cover sheet" + requirement: always + description: "Cook County asks for form CCDR 0001 on every new case." + financial_affidavit: + # Cook County Local Rule 13.3.1 asks for the standard financial + # affidavit in every contested money question, so it moves up a level. + requirement: always + + "cook:cvd1": # Cook County Circuit Court - Municipal Civil Division + case_types: + eviction: + documents: + early_resolution_program_notice: + label: "Early Resolution Program notice" + requirement: always + description: "Cook County sends eviction cases to this free program for help and mediation." + + "bond": # Bond Court case_types: name_change: # Hide both sections for Bond Court diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index c7b82c3..822b379 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -604,6 +604,106 @@ margin: 0.2rem 0 0; } +.document-plan { + background: var(--surface-subtle); + border: 1px solid var(--border-default); + border-radius: 12px; + margin-bottom: 1.75rem; + padding: 1.25rem; +} + +.document-plan h2 { + color: var(--text-heading); + font-size: 1.25rem; + font-weight: 750; + margin: 0; +} + +.document-plan__lede { + color: var(--text-muted); + margin: 0.35rem 0 1.1rem; +} + +.document-plan__group { + border: 0; + margin: 0 0 1.1rem; + padding: 0; +} + +.document-plan__group:last-of-type { + margin-bottom: 0.75rem; +} + +.document-plan__level { + color: var(--text-heading); + float: none; + font-size: 0.78rem; + font-weight: 750; + letter-spacing: 0.06em; + margin: 0 0 0.5rem; + text-transform: uppercase; + width: auto; +} + +.document-plan__level::before { + border-radius: 50%; + content: ""; + display: inline-block; + height: 9px; + margin-right: 0.45rem; + vertical-align: baseline; + width: 9px; +} + +.document-plan__level--always::before { + background: var(--danger-icon); +} + +.document-plan__level--usually::before { + background: var(--better-blue); +} + +.document-plan__level--sometimes::before { + background: var(--border-strong); +} + +.document-plan__item { + align-items: flex-start; + background: #fff; + border: 1px solid var(--border-subtle); + border-radius: 10px; + display: flex; + gap: 0.7rem; + margin-bottom: 0.5rem; + padding: 0.7rem 0.85rem; +} + +.document-plan__item:last-child { + margin-bottom: 0; +} + +.document-plan__item:has(input:checked) { + background: var(--success-surface); + border-color: var(--success-border); +} + +.document-plan__item strong { + color: var(--text-heading); + font-weight: 650; +} + +.document-plan__item small { + color: var(--text-muted); + display: block; +} + +.checklist-files__heading { + color: var(--text-heading); + font-size: 1.25rem; + font-weight: 750; + margin: 0 0 0.75rem; +} + .checklist-files { border: 1px solid var(--border-default); border-radius: 12px; diff --git a/efile_app/efile/templates/efile/confirmation.html b/efile_app/efile/templates/efile/confirmation.html index 9f7197e..ecbca3a 100644 --- a/efile_app/efile/templates/efile/confirmation.html +++ b/efile_app/efile/templates/efile/confirmation.html @@ -69,6 +69,15 @@

{% translate "Your filing was sent to the court" %}

class="btn btn-primary"> {% translate "View filing status" %} + {% if draft.plan_id %} +
+ {% csrf_token %} + +
+ {% endif %} {% translate "Return to filing options" %} diff --git a/efile_app/efile/templates/efile/document_checklist.html b/efile_app/efile/templates/efile/document_checklist.html index 0160d3a..421ba32 100644 --- a/efile_app/efile/templates/efile/document_checklist.html +++ b/efile_app/efile/templates/efile/document_checklist.html @@ -15,11 +15,51 @@

{% translate "Do you have all your documents?" %}

{% translate "Before you continue" %} -

- {% translate "Include every completed court form, exhibit, translation, or proposed order that belongs with this filing. This list cannot tell you which legal forms your case needs." %} -

+ {% if checklist_groups %} +

+ {% blocktranslate with court=plan.court_name|default:filing_draft.court_name %}The list below is a guide for cases like yours at {{ court }}. Your case may need more or fewer documents.{% endblocktranslate %} +

+ {% else %} +

+ {% translate "Include every completed court form, exhibit, translation, or proposed order that belongs with this filing. This list cannot tell you which legal forms your case needs." %} +

+ {% endif %}
+ {% if checklist_groups %} +
+

{% translate "Your document plan" %}

+

+ {% translate "Check off what you have. We save this list, so you can come back to it." %} +

+ {% for group in checklist_groups %} +
+ {{ group.label }} + {% for item in group.items %} + + {% endfor %} +
+ {% endfor %} + +
+ {% endif %} +

{% translate "Files you have added" %}

{% for document in documents %}
diff --git a/efile_app/efile/tests/test_document_checklists.py b/efile_app/efile/tests/test_document_checklists.py new file mode 100644 index 0000000..9b4f864 --- /dev/null +++ b/efile_app/efile/tests/test_document_checklists.py @@ -0,0 +1,301 @@ +"""Tests for partner-configured document checklists. + +Most tests build a small YAML configuration in a temporary directory, so they +describe the contract partners write against rather than the Illinois content of +the moment. The last group checks the shipped Illinois configuration against the +names the courts actually publish. +""" + +from unittest.mock import patch + +import pytest +import yaml + +from efile.services.document_checklists import normalize_name, resolve_document_checklist +from efile.utils.config_loader import JurisdictionConfigLoader + +BASE_CONFIG = { + "base_case_types": { + "name_change": { + "matches": {"names": ["Name Change"]}, + "documents": { + "petition": {"label": "Request to change a name", "requirement": "always", "role": "lead"}, + "fee_waiver": {"label": "Request to waive court fees", "requirement": "sometimes"}, + }, + } + } +} + +STATE_CONFIG = { + "case_types": { + "name_change": { + "extends": "base_case_types.name_change", + "matches": {"names": ["Name Change", "Change of Name"], "aliases": ["Petition - Change of Name"]}, + "documents": { + "publication_notice": {"label": "Proof of newspaper notice", "requirement": "usually"}, + "minor_consent": { + "label": "Consent from the child", + "requirement": "sometimes", + "when": {"lead_filing_type_names": ["Request for Name Change (Minor Children)"]}, + }, + }, + }, + "eviction": { + "matches": {"names": ["Residential - Eviction"]}, + "documents": {"answer": {"label": "Answer", "requirement": "always"}}, + }, + }, + "case_categories": { + "miscellaneous_remedy": { + "matches": {"names": ["Miscellaneous Remedy"]}, + "documents": {"supporting_records": {"label": "Papers that back up your side", "requirement": "usually"}}, + } + }, + "court_specific_requirements": { + "cook:cd1": { + "case_types": { + "name_change": { + "documents": { + "publication_notice": {"requirement": "always"}, + "cover_sheet": {"label": "County cover sheet", "requirement": "always"}, + "fee_waiver": {"include": False}, + } + } + } + } + }, +} + + +@pytest.fixture +def checklist_config(tmp_path): + """Point the checklist resolver at a throwaway partner configuration.""" + + (tmp_path / "base-case-types.yaml").write_text(yaml.safe_dump(BASE_CONFIG)) + (tmp_path / "states").mkdir(exist_ok=True) + (tmp_path / "states" / "testland.yaml").write_text(yaml.safe_dump(STATE_CONFIG)) + + loader = JurisdictionConfigLoader(config_dir=tmp_path) + with patch("efile.services.document_checklists.config_loader", loader): + yield loader + + +def test_normalize_name_folds_case_spacing_and_dashes(): + assert normalize_name(" Petition for Dissolution of Marriage – Children ") == ( + "petition for dissolution of marriage - children" + ) + assert normalize_name("Affidavit Filed ") == "affidavit filed" + assert normalize_name(None) == "" + + +def test_case_type_checklist_merges_base_and_state_and_orders_by_requirement(checklist_config): + checklist = resolve_document_checklist("testland", case_type_name="Change of Name") + + assert list(checklist) == ["petition", "publication_notice", "fee_waiver"] + assert checklist["petition"] == { + "label": "Request to change a name", + "requirement": "always", + "role": "lead", + } + assert checklist["publication_notice"]["requirement"] == "usually" + + +def test_checklist_holds_no_court_codes(checklist_config): + checklist = resolve_document_checklist("testland", case_type_name="Name Change") + + for item in checklist.values(): + assert set(item) <= {"label", "requirement", "description", "role"} + + +def test_matching_ignores_case_spacing_and_dash_style(checklist_config): + assert resolve_document_checklist("testland", case_type_name=" CHANGE OF NAME ") + assert resolve_document_checklist("testland", case_type_name="Residential – Eviction") + + +def test_matching_accepts_configured_aliases(checklist_config): + assert resolve_document_checklist("testland", case_type_name="Petition - Change of Name") + + +def test_matching_is_not_fuzzy(checklist_config): + """A name nobody configured gets no checklist, rather than a near miss.""" + + assert resolve_document_checklist("testland", case_type_name="Name Change Petition") == {} + assert resolve_document_checklist("testland", case_type_name="Eviction") == {} + + +def test_court_override_adds_changes_and_removes_items(checklist_config): + everywhere = resolve_document_checklist("testland", case_type_name="Name Change") + cook = resolve_document_checklist("testland", court_code="cook:cd1", case_type_name="Name Change") + + assert everywhere["publication_notice"]["requirement"] == "usually" + assert "fee_waiver" in everywhere + assert "cover_sheet" not in everywhere + + assert cook["publication_notice"]["requirement"] == "always" + assert cook["cover_sheet"]["label"] == "County cover sheet" + assert "fee_waiver" not in cook + # An override changes one item without dropping the rest of the list. + assert cook["petition"]["label"] == "Request to change a name" + + +def test_court_override_does_not_leak_to_other_courts(checklist_config): + dupage = resolve_document_checklist("testland", court_code="dupage", case_type_name="Change of Name") + + assert "cover_sheet" not in dupage + + +def test_filing_type_condition_filters_items(checklist_config): + without_lead = resolve_document_checklist("testland", case_type_name="Name Change") + minor = resolve_document_checklist( + "testland", + case_type_name="Name Change", + lead_filing_type_name="Request for Name Change (Minor Children)", + ) + adult = resolve_document_checklist( + "testland", + case_type_name="Name Change", + lead_filing_type_name="Request for Name Change (Adult)", + ) + + assert "minor_consent" not in without_lead + assert "minor_consent" in minor + assert "minor_consent" not in adult + + +def test_category_guidance_is_the_fallback(checklist_config): + checklist = resolve_document_checklist( + "testland", + case_category_name="Miscellaneous Remedy", + case_type_name="Something The Config Does Not Know", + ) + + assert list(checklist) == ["supporting_records"] + + +def test_case_type_checklist_replaces_category_guidance(checklist_config): + checklist = resolve_document_checklist( + "testland", + case_category_name="Miscellaneous Remedy", + case_type_name="Name Change", + ) + + assert "supporting_records" not in checklist + assert "petition" in checklist + + +def test_unmatched_case_returns_no_checklist(checklist_config): + assert resolve_document_checklist("testland", case_type_name="Tax Sale") == {} + assert resolve_document_checklist("testland") == {} + assert resolve_document_checklist("") == {} + + +def test_unknown_requirement_falls_back_to_sometimes(tmp_path): + (tmp_path / "base-case-types.yaml").write_text(yaml.safe_dump({})) + (tmp_path / "states").mkdir(exist_ok=True) + (tmp_path / "states" / "testland.yaml").write_text( + yaml.safe_dump( + { + "case_types": { + "name_change": { + "matches": {"names": ["Name Change"]}, + "documents": {"petition": {"label": "Petition", "requirement": "mandatory"}}, + } + } + } + ) + ) + loader = JurisdictionConfigLoader(config_dir=tmp_path) + + with patch("efile.services.document_checklists.config_loader", loader): + checklist = resolve_document_checklist("testland", case_type_name="Name Change") + + assert checklist["petition"]["requirement"] == "sometimes" + + +class TestShippedIllinoisConfig: + """Check the Illinois configuration against names the live courts publish.""" + + def test_cook_county_name_change(self): + checklist = resolve_document_checklist( + "illinois", + court_code="cook:cd1", + case_category_name="Miscellaneous", + case_type_name="Name Change", + lead_filing_type_name="Petition for Name Change", + ) + + assert checklist["petition"]["requirement"] == "always" + assert checklist["county_division_cover_sheet"]["requirement"] == "always" + assert "minor_consent" not in checklist + + def test_name_change_outside_cook_county(self): + checklist = resolve_document_checklist( + "illinois", + court_code="lake", + case_category_name="Miscellaneous Remedy", + case_type_name="Change of Name", + lead_filing_type_name="Request for Name Change (Minor Children)", + ) + + assert "county_division_cover_sheet" not in checklist + assert "minor_consent" in checklist + assert checklist["publication_notice"]["requirement"] == "usually" + + def test_dissolution_names_match_despite_dash_style(self): + """Cook County spells one case type with a dash and its pair with a hyphen.""" + + with_children = resolve_document_checklist( + "illinois", + court_code="cook:dr1", + case_type_name="Petition for Dissolution of Marriage – Children", + ) + without_children = resolve_document_checklist( + "illinois", + court_code="cook:dr1", + case_type_name="Petition for Dissolution of Marriage - No Children", + ) + + assert with_children["petition"]["requirement"] == "always" + assert with_children["financial_affidavit"]["requirement"] == "always" + assert without_children["domestic_relations_cover_sheet"]["requirement"] == "always" + + def test_dissolution_outside_cook_county_keeps_state_wide_levels(self): + checklist = resolve_document_checklist( + "illinois", + court_code="dupage", + case_category_name="Dissolution (Divorce) with Children", + case_type_name="Dissolution (with children)", + ) + + assert checklist["financial_affidavit"]["requirement"] == "usually" + assert "domestic_relations_cover_sheet" not in checklist + + def test_eviction_checklist_depends_on_who_is_filing(self): + landlord = resolve_document_checklist( + "illinois", + court_code="cook:cvd1", + case_type_name="Eviction - Possession - Residential Complaint Filed - Non-Jury", + lead_filing_type_name="Complaint / Petition - Eviction - Residential - Possession Only - Fee", + ) + tenant = resolve_document_checklist( + "illinois", + court_code="cook:cvd1", + case_type_name="Eviction - Possession - Residential Complaint Filed - Non-Jury", + lead_filing_type_name="Appearance Filed - Eviction - Possession Only", + ) + + assert "complaint" in landlord + assert "answer" not in landlord + assert "appearance" in tenant + assert "answer" in tenant + assert tenant["early_resolution_program_notice"]["requirement"] == "always" + + def test_category_guidance_for_a_case_type_with_no_checklist(self): + checklist = resolve_document_checklist( + "illinois", + court_code="dupage", + case_category_name="Small Claims", + case_type_name="Contract - Debt Collection (Seller/Plaintiff) (Up to $2,500)", + ) + + assert list(checklist) == ["supporting_records", "proof_of_service", "fee_waiver"] diff --git a/efile_app/efile/tests/test_document_prep.py b/efile_app/efile/tests/test_document_prep.py index c8009d7..c6b066d 100644 --- a/efile_app/efile/tests/test_document_prep.py +++ b/efile_app/efile/tests/test_document_prep.py @@ -82,6 +82,78 @@ def test_document_checklist_adds_missing_documents_inline(client, document_draft assert upload_files.call_args.kwargs["current_step"] == WorkflowStepKey.DOCUMENT_CHECKLIST +@pytest.fixture +def planned_draft(document_draft): + """A draft whose case is one the Illinois configuration has guidance for.""" + + document_draft.court_code = "cook:cd1" + document_draft.court_name = "Cook County Circuit Court - County Division" + document_draft.case_category_name = "Miscellaneous" + document_draft.case_type_name = "Name Change" + document_draft.save() + lead = document_draft.documents.get(role=FilingDocument.Role.LEAD) + lead.filing_type_name = "Petition for Name Change" + lead.save(update_fields=["filing_type_name", "updated_at"]) + return document_draft + + +@pytest.mark.django_db +def test_document_checklist_shows_configured_guidance(client, planned_draft): + response = client.get(reverse("document_checklist", kwargs={"jurisdiction": "illinois"})) + + planned_draft.refresh_from_db() + page = response.content.decode() + assert response.status_code == 200 + assert "Your document plan" in page + assert "Always needed" in page + assert "Request for name change" in page + assert "County Division information sheet" in page + assert planned_draft.plan.checklist["petition"]["requirement"] == "always" + + +@pytest.mark.django_db +def test_document_checklist_keeps_its_disclaimer_without_configured_guidance(client, document_draft): + response = client.get(reverse("document_checklist", kwargs={"jurisdiction": "illinois"})) + + document_draft.refresh_from_db() + page = response.content.decode() + assert "Your document plan" not in page + assert "cannot tell you which legal forms your case needs" in page + assert document_draft.plan_id is None + + +@pytest.mark.django_db +def test_document_checklist_saves_gathered_documents(client, planned_draft): + response = client.post( + reverse("document_checklist", kwargs={"jurisdiction": "illinois"}), + {"action": "save_progress", "gathered": ["petition", "proposed_order"]}, + ) + + planned_draft.refresh_from_db() + checklist = planned_draft.plan.checklist + assert response.status_code == 302 + assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert checklist["petition"]["complete"] is True + assert checklist["proposed_order"]["complete"] is True + assert checklist["publication_notice"]["complete"] is False + # Saving the matter checklist is not the same as saying this filing is ready. + assert planned_draft.document_checklist_acknowledged is False + + +@pytest.mark.django_db +def test_document_checklist_saves_gathered_documents_when_continuing(client, planned_draft): + response = client.post( + reverse("document_checklist", kwargs={"jurisdiction": "illinois"}), + {"documents_complete": "yes", "gathered": ["petition"]}, + ) + + planned_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + assert planned_draft.document_checklist_acknowledged is True + assert planned_draft.plan.checklist["petition"]["complete"] is True + + @pytest.mark.django_db def test_organize_requires_completed_checklist(client, document_draft): response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) diff --git a/efile_app/efile/tests/test_filing_plans.py b/efile_app/efile/tests/test_filing_plans.py new file mode 100644 index 0000000..5122a8d --- /dev/null +++ b/efile_app/efile/tests/test_filing_plans.py @@ -0,0 +1,316 @@ +"""Tests for the filer's plan: the matter a checklist and its filings belong to.""" + +from unittest.mock import Mock, patch + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingPlan +from efile.services.filing_plans import ( + create_draft_from_plan, + ensure_plan_for_draft, + grouped_checklist, + resolve_plan_case_codes, + set_checklist_progress, +) +from efile.workflow import ExistingCase, WorkflowStepKey + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create_user(username="plan-user", tyler_jurisdiction="illinois") + + +def make_draft(user, **overrides): + fields = { + "jurisdiction": "illinois", + "workflow_version": 2, + "existing_case": ExistingCase.NEW, + "court_code": "cook:cd1", + "court_name": "Cook County Circuit Court - County Division", + "case_category_code": "78332", + "case_category_name": "Miscellaneous", + "case_type_code": "78346", + "case_type_name": "Name Change", + "current_step": WorkflowStepKey.DOCUMENT_CHECKLIST, + } + fields.update(overrides) + draft = FilingDraft.objects.create(user=user, **fields) + FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="petition.pdf", + filing_type_code="78690", + filing_type_name="Petition for Name Change", + ) + return draft + + +def make_plan(draft) -> FilingPlan: + """Plan a draft the Illinois configuration covers, so the plan is never None.""" + + plan = ensure_plan_for_draft(draft) + assert plan is not None + return plan + + +@pytest.mark.django_db +def test_plan_snapshots_the_configured_checklist(user): + draft = make_draft(user) + + plan = make_plan(draft) + + draft.refresh_from_db() + assert draft.plan == plan + assert plan.title == "Name Change" + assert plan.case_type_name == "Name Change" + assert plan.lead_filing_type_name == "Petition for Name Change" + assert plan.checklist["petition"]["requirement"] == "always" + assert plan.checklist["petition"]["complete"] is False + + +@pytest.mark.django_db +def test_plan_keeps_no_court_codes(user): + """A plan that stored a code would file against a stale one months later.""" + + plan = make_plan(make_draft(user)) + + saved = {field.name for field in FilingPlan._meta.get_fields()} + assert not {name for name in saved if name.endswith("_code")} - {"court_code"} + for item in plan.checklist.values(): + assert "code" not in item + + +@pytest.mark.django_db +def test_no_plan_for_a_case_type_no_partner_configured(user): + draft = make_draft(user, case_category_name="Tax", case_type_name="Action in Debt") + + assert ensure_plan_for_draft(draft) is None + draft.refresh_from_db() + assert draft.plan is None + assert FilingPlan.objects.count() == 0 + + +@pytest.mark.django_db +def test_plan_is_created_once_per_draft(user): + draft = make_draft(user) + + first = make_plan(draft) + second = make_plan(draft) + + assert first.pk == second.pk + assert FilingPlan.objects.count() == 1 + + +@pytest.mark.django_db +def test_plan_follows_the_draft_when_the_filer_picks_a_different_case(user): + draft = make_draft(user) + plan = make_plan(draft) + set_checklist_progress(plan, ["petition", "fee_waiver"]) + + draft.court_code = "cook:dr1" + draft.court_name = "Cook County Circuit Court - Domestic Relations Division" + draft.case_category_name = "Domestic Relations - General Proceedings" + draft.case_type_name = "Petition for Dissolution of Marriage - No Children" + draft.save() + refreshed = make_plan(draft) + + assert refreshed.pk == plan.pk + assert refreshed.title == "Petition for Dissolution of Marriage - No Children" + assert "publication_notice" not in refreshed.checklist + assert refreshed.checklist["domestic_relations_cover_sheet"]["requirement"] == "always" + # Progress on an item the new case still asks for is the filer's, and stays. + assert refreshed.checklist["fee_waiver"]["complete"] is True + + +@pytest.mark.django_db +def test_a_draft_that_becomes_an_unconfigured_case_loses_the_guidance(user): + draft = make_draft(user) + make_plan(draft) + + draft.case_category_name = "Tax" + draft.case_type_name = "Action in Debt" + draft.save() + + assert ensure_plan_for_draft(draft) is None + draft.refresh_from_db() + assert draft.plan is None + + +@pytest.mark.django_db +def test_a_matter_with_other_filings_keeps_its_own_case(user): + first = make_draft(user) + plan = make_plan(first) + second = make_draft(user, case_type_name="Change of Name") + second.plan = plan + second.save(update_fields=["plan", "updated_at"]) + + own_plan = make_plan(second) + + plan.refresh_from_db() + assert own_plan.pk != plan.pk + assert plan.case_type_name == "Name Change" + assert own_plan.case_type_name == "Change of Name" + + +@pytest.mark.django_db +def test_saved_checklist_does_not_change_when_partner_config_changes(user): + draft = make_draft(user) + plan = make_plan(draft) + set_checklist_progress(plan, ["petition"]) + + with patch( + "efile.services.filing_plans.resolve_document_checklist", + return_value={"something_new": {"label": "New", "requirement": "always"}}, + ): + ensure_plan_for_draft(draft) + + plan.refresh_from_db() + assert "something_new" not in plan.checklist + assert plan.checklist["petition"]["complete"] is True + + +@pytest.mark.django_db +def test_progress_only_records_items_the_plan_knows(user): + plan = make_plan(make_draft(user)) + + set_checklist_progress(plan, ["petition", "not_a_real_item"]) + + plan.refresh_from_db() + assert plan.checklist["petition"]["complete"] is True + assert plan.checklist["proposed_order"]["complete"] is False + assert "not_a_real_item" not in plan.checklist + + +@pytest.mark.django_db +def test_progress_survives_the_filing_it_started_with(user): + draft = make_draft(user) + plan = make_plan(draft) + set_checklist_progress(plan, ["petition"]) + + draft.mark_submitted({"confirmation_number": "abc"}) + draft.delete() + + plan.refresh_from_db() + assert plan.checklist["petition"]["complete"] is True + + +@pytest.mark.django_db +def test_one_filer_can_keep_several_plans_of_the_same_case_type(user): + mine = make_plan(make_draft(user)) + my_childs = make_plan(make_draft(user)) + my_childs.title = "My child's name change" + my_childs.save(update_fields=["title", "updated_at"]) + + assert mine.pk != my_childs.pk + assert FilingPlan.objects.filter(user=user).count() == 2 + + +@pytest.mark.django_db +def test_grouped_checklist_orders_levels_and_keeps_progress(user): + plan = make_plan(make_draft(user)) + set_checklist_progress(plan, ["petition"]) + + groups = grouped_checklist(plan) + + assert [group["requirement"] for group in groups] == ["always", "usually", "sometimes"] + assert groups[0]["label"] == "Always needed" + assert groups[0]["items"][0]["complete"] is True + assert grouped_checklist(None) == [] + + +def fake_codes(*, categories, case_types, filing_types): + """Stand in for the court's live code lists, which renumber over time.""" + + def get(url, params=None, timeout=None): + if url.endswith("/categories"): + payload = categories + elif url.endswith("/case_types/"): + payload = case_types + else: + payload = filing_types + return Mock(raise_for_status=Mock(), json=Mock(return_value=payload)) + + return get + + +@pytest.mark.django_db +def test_plan_names_resolve_to_todays_codes(user): + plan = make_plan(make_draft(user)) + + with patch( + "efile.services.filing_plans.requests.get", + side_effect=fake_codes( + categories=[{"code": "999001", "name": "Miscellaneous"}], + case_types=[{"code": "999002", "name": "Name Change"}], + filing_types=[{"code": "999003", "name": "Petition for Name Change"}], + ), + ): + codes = resolve_plan_case_codes(plan) + + assert codes == { + "case_category_code": "999001", + "case_type_code": "999002", + "lead_filing_type_code": "999003", + } + + +@pytest.mark.django_db +def test_a_name_the_court_dropped_resolves_to_nothing(user): + plan = make_plan(make_draft(user)) + + with patch( + "efile.services.filing_plans.requests.get", + side_effect=fake_codes( + categories=[{"code": "999001", "name": "Miscellaneous"}], + case_types=[{"code": "999002", "name": "Change of Name"}], + filing_types=[], + ), + ): + codes = resolve_plan_case_codes(plan) + + assert codes["case_category_code"] == "999001" + assert codes["case_type_code"] == "" + assert codes["lead_filing_type_code"] == "" + + +@pytest.mark.django_db +def test_starting_another_filing_needs_your_own_plan(client, user, django_user_model): + plan = make_plan(make_draft(user)) + someone_else = django_user_model.objects.create_user(username="other-user", tyler_jurisdiction="illinois") + client.force_login(someone_else) + session = client.session + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session["jurisdiction"] = "illinois" + session.save() + + response = client.post(reverse("start_filing_from_plan", kwargs={"jurisdiction": "illinois", "plan_id": plan.pk})) + + assert response.status_code == 404 + assert FilingDraft.objects.filter(user=someone_else).count() == 0 + + +@pytest.mark.django_db +def test_another_filing_from_a_plan_uses_todays_codes(user): + first = make_draft(user) + plan = make_plan(first) + + with patch( + "efile.services.filing_plans.requests.get", + side_effect=fake_codes( + categories=[{"code": "999001", "name": "Miscellaneous"}], + case_types=[{"code": "999002", "name": "Name Change"}], + filing_types=[], + ), + ): + second = create_draft_from_plan(user, plan) + + assert second.pk != first.pk + assert second.plan == plan + assert second.case_type_name == "Name Change" + # Yesterday's code was 78346; the draft files under whatever it is today. + assert second.case_type_code == "999002" + assert second.case_category_code == "999001" + assert second.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS + assert list(FilingDraft.objects.filter(plan=plan).order_by("pk")) == [first, second] diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 2809b14..34d4426 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -9,7 +9,7 @@ from .views.choose_jurisdiction import choose_jurisdiction from .views.confirmation import filing_confirmation from .views.document_checklist import document_checklist -from .views.draft_views import create_draft_view, get_current_draft_view +from .views.draft_views import create_draft_view, get_current_draft_view, start_filing_from_plan from .views.extraction_review import extraction_review from .views.filing_path import filing_path from .views.filing_statuses import filing_statuses @@ -67,6 +67,11 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//party-details/", party_details, name="party_details"), path("jurisdiction//case-questions/", case_questions, name="case_questions"), path("jurisdiction//drafts/", create_draft_view, name="create_draft"), + path( + "jurisdiction//plans//filings/", + start_filing_from_plan, + name="start_filing_from_plan", + ), path("jurisdiction//filing_statuses/", filing_statuses, name="filing_statuses"), path( "jurisdiction//expert_form/", diff --git a/efile_app/efile/utils/config_loader.py b/efile_app/efile/utils/config_loader.py index 5234fc5..64e2c8d 100644 --- a/efile_app/efile/utils/config_loader.py +++ b/efile_app/efile/utils/config_loader.py @@ -133,6 +133,47 @@ def _deep_merge(base, overlay): def get_short_jurisdiction_config(self, jurisdiction): return self.load_jurisdiction_config(jurisdiction)["jurisdiction"] + def get_document_checklist_config(self, jurisdiction, court=None): + """ + Get the config sections that carry partner document checklists. + + Case types and case categories are returned separately because a + checklist is matched against the court's own case type name first and + its case category name only as a fallback. Court entries in + ``court_specific_requirements`` are deep merged in, so one court can + change a single checklist item, or add its own local form, without + restating the rest of the list. + + Args: + jurisdiction (str): The jurisdiction code + court (str, optional): Court code whose overrides should be applied + + Returns: + dict: {"case_types": {...}, "case_categories": {...}} + """ + jurisdiction_config = self.load_jurisdiction_config(jurisdiction) or {} + + # Base case types are the shared starting point; a state's own case_types + # (already merged with whatever they extend) layer on top. + case_types = JurisdictionConfigLoader._deep_merge( + jurisdiction_config.get("base_case_types") or {}, + jurisdiction_config.get("case_types") or {}, + ) + sections = { + "case_types": case_types, + "case_categories": deepcopy(jurisdiction_config.get("case_categories") or {}), + } + + court_requirements = (jurisdiction_config.get("court_specific_requirements") or {}).get(court or "", {}) + for section_name, section in sections.items(): + overrides = court_requirements.get(section_name) or {} + for key, override in overrides.items(): + if not isinstance(override, dict): + continue + section[key] = JurisdictionConfigLoader._deep_merge(section.get(key, {}), override) + + return sections + def _find_with_keywords(self, key, cases): """ Given a dictionary with keys and keywords (as a list in the key's value), diff --git a/efile_app/efile/views/document_checklist.py b/efile_app/efile/views/document_checklist.py index 0a04cd6..a9eee24 100644 --- a/efile_app/efile/views/document_checklist.py +++ b/efile_app/efile/views/document_checklist.py @@ -8,6 +8,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_plans import ensure_plan_for_draft, grouped_checklist, set_checklist_progress from efile.workflow import WorkflowStepKey, get_step_url, get_workflow_context @@ -45,7 +46,16 @@ def document_checklist(request, jurisdiction): draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) return JsonResponse({"success": True, "document_count": FilingDocument.objects.filter(draft=draft).count()}) + # The plan holds the filer's own list for this matter. It outlives this + # filing, so it is created here and only read from the draft. + plan = ensure_plan_for_draft(draft) + if request.method == "POST": + if plan is not None: + set_checklist_progress(plan, request.POST.getlist("gathered")) + if request.POST.get("action") == "save_progress": + messages.success(request, "We saved your document list.") + return redirect("document_checklist", jurisdiction=jurisdiction) if request.POST.get("documents_complete") != "yes": messages.error(request, "Confirm that you have added every document you want to file.") else: @@ -58,6 +68,8 @@ def document_checklist(request, jurisdiction): "is_logged_in": True, "filing_draft": draft_snapshot(draft), "documents": documents, + "plan": plan, + "checklist_groups": grouped_checklist(plan), } context.update(get_workflow_context(WorkflowStepKey.DOCUMENT_CHECKLIST, jurisdiction, draft)) return render(request, "efile/document_checklist.html", context) diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index 4bd4cbb..b55e1d4 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -1,12 +1,16 @@ import json import logging +from django.contrib import messages from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect from django.views.decorators.http import require_http_methods from efile.api.suffolk_api_views import get_tyler_token -from efile.services.current_drafts import create_current_draft, get_current_draft +from efile.models import FilingPlan +from efile.services.current_drafts import attach_current_draft, create_current_draft, get_current_draft from efile.services.drafts import draft_snapshot +from efile.services.filing_plans import create_draft_from_plan from efile.utils.django_helpers import flush_cache_stay_logged_in from efile.workflow import WorkflowStepKey, get_step_url @@ -47,6 +51,29 @@ def create_draft_view(request, jurisdiction): ) +@require_http_methods(["POST"]) +def start_filing_from_plan(request, jurisdiction, plan_id): + """Start another filing in a matter the filer already has a plan for. + + The plan's court, case category, and case type are carried over by name and + resolved against the court's current code lists, so an old plan never files + against a code the court has since reused for something else. + """ + + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + plan = get_object_or_404(FilingPlan, pk=plan_id, user=request.user, jurisdiction=jurisdiction) + + flush_cache_stay_logged_in(request.session) + draft = create_draft_from_plan(request.user, plan) + attach_current_draft(request, draft) + logger.info("Started draft id=%s from plan id=%s", draft.pk, plan.pk) + + messages.success(request, f"We started another filing for {plan.title}.") + return redirect(get_step_url(WorkflowStepKey.UPLOAD_DOCUMENTS, jurisdiction)) + + @require_http_methods(["GET"]) def get_current_draft_view(request): """Return the durable draft attached to this session/user.""" From 26ff1994a2708221ad5b470e5e8de696f224c4ad Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sun, 16 Aug 2026 12:17:39 -0400 Subject: [PATCH 2/5] Add migration needed for ci/cd --- ...ing_plans_and_jurisdiction_accounts_and_workflow.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 efile_app/efile/migrations/0012_merge_filing_plans_and_jurisdiction_accounts_and_workflow.py diff --git a/efile_app/efile/migrations/0012_merge_filing_plans_and_jurisdiction_accounts_and_workflow.py b/efile_app/efile/migrations/0012_merge_filing_plans_and_jurisdiction_accounts_and_workflow.py new file mode 100644 index 0000000..aa2d733 --- /dev/null +++ b/efile_app/efile/migrations/0012_merge_filing_plans_and_jurisdiction_accounts_and_workflow.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0011_filing_plans"), + ("efile", "0011_merge_jurisdiction_accounts_and_workflow"), + ] + + operations = [] From 847de9322746423e22fa12cd20536c0deded20c7 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 17 Aug 2026 15:15:21 -0400 Subject: [PATCH 3/5] Iteration on first draft; smarter handling of roles --- .../illinois_filing_requirements_by_county.md | 494 ++++++++++++++++++ docs/interactive_testing_guide.md | 150 ++++++ efile_app/efile/api/config_views.py | 28 + efile_app/efile/api/urls.py | 3 +- ..._plan_case_link_and_checklist_documents.py | 30 ++ efile_app/efile/migrations/0014_filer_role.py | 20 + .../efile/migrations/0015_plan_guidance.py | 15 + efile_app/efile/models.py | 37 +- efile_app/efile/services/current_drafts.py | 115 +++- .../efile/services/document_checklists.py | 243 ++++++++- efile_app/efile/services/drafts.py | 16 + efile_app/efile/services/efsp_payload.py | 20 +- efile_app/efile/services/filing_plans.py | 486 ++++++++++++++++- efile_app/efile/services/people.py | 39 +- efile_app/efile/static/config/README.md | 125 ++++- .../efile/static/config/base-case-types.yaml | 7 + .../efile/static/config/states/illinois.yaml | 197 +++++-- efile_app/efile/static/css/filing_plans.css | 132 +++++ efile_app/efile/static/css/options.css | 26 + .../efile/static/css/reorganized-flow.css | 318 ++++++++++- efile_app/efile/static/js/checklist-status.js | 25 + .../efile/static/js/extraction-review.js | 94 +++- efile_app/efile/static/js/filing-plans.js | 88 ++++ .../efile/static/js/organize-documents.js | 10 +- .../components/checklist_item_status.html | 38 ++ .../efile/components/plan_about.html | 26 + .../efile/components/profile_header.html | 9 + .../templates/efile/document_checklist.html | 168 +++++- .../templates/efile/extraction_review.html | 13 + .../efile/templates/efile/filing_plans.html | 185 +++++++ efile_app/efile/templates/efile/options.html | 100 ++-- efile_app/efile/templates/efile/review.html | 27 + .../tests/test_checklist_filing_types.py | 178 +++++++ .../tests/test_current_draft_selection.py | 189 +++++++ .../efile/tests/test_document_checklists.py | 185 ++++++- efile_app/efile/tests/test_document_prep.py | 12 +- efile_app/efile/tests/test_durable_drafts.py | 2 +- efile_app/efile/tests/test_efsp_payload.py | 46 +- efile_app/efile/tests/test_filer_role.py | 305 +++++++++++ .../efile/tests/test_filing_plan_actions.py | 441 ++++++++++++++++ efile_app/efile/tests/test_filing_plans.py | 14 +- .../tests/test_plan_status_and_guidance.py | 337 ++++++++++++ .../efile/tests/test_review_submit_flow.py | 4 +- efile_app/efile/tests/test_workflow.py | 13 +- efile_app/efile/urls.py | 2 + efile_app/efile/views/case_confirmation.py | 10 + efile_app/efile/views/case_lookup.py | 6 + efile_app/efile/views/document_checklist.py | 158 +++++- efile_app/efile/views/draft_views.py | 5 +- efile_app/efile/views/extraction_review.py | 32 ++ efile_app/efile/views/filing_plans.py | 85 +++ efile_app/efile/views/legacy_workflow.py | 4 +- efile_app/efile/views/options.py | 13 +- efile_app/efile/views/review.py | 5 + efile_app/efile/workflow.py | 19 +- 55 files changed, 5100 insertions(+), 249 deletions(-) create mode 100644 docs/illinois_filing_requirements_by_county.md create mode 100644 docs/interactive_testing_guide.md create mode 100644 efile_app/efile/migrations/0013_plan_case_link_and_checklist_documents.py create mode 100644 efile_app/efile/migrations/0014_filer_role.py create mode 100644 efile_app/efile/migrations/0015_plan_guidance.py create mode 100644 efile_app/efile/static/css/filing_plans.css create mode 100644 efile_app/efile/static/js/checklist-status.js create mode 100644 efile_app/efile/static/js/filing-plans.js create mode 100644 efile_app/efile/templates/efile/components/checklist_item_status.html create mode 100644 efile_app/efile/templates/efile/components/plan_about.html create mode 100644 efile_app/efile/templates/efile/filing_plans.html create mode 100644 efile_app/efile/tests/test_checklist_filing_types.py create mode 100644 efile_app/efile/tests/test_current_draft_selection.py create mode 100644 efile_app/efile/tests/test_filer_role.py create mode 100644 efile_app/efile/tests/test_filing_plan_actions.py create mode 100644 efile_app/efile/tests/test_plan_status_and_guidance.py create mode 100644 efile_app/efile/views/filing_plans.py diff --git a/docs/illinois_filing_requirements_by_county.md b/docs/illinois_filing_requirements_by_county.md new file mode 100644 index 0000000..f3eb553 --- /dev/null +++ b/docs/illinois_filing_requirements_by_county.md @@ -0,0 +1,494 @@ +# Illinois E-Filing Document Requirements by County +## Research and Live EFSP Proxy Validation for High-Volume Filing Needs + +This document provides a comprehensive analysis and empirical validation of the documents, filing codes, party requirements, and court-specific rules for **five high-volume Illinois civil court filing needs**. + +The findings are validated directly against the live development e-file proxy server (`https://efile-test.suffolklitlab.org` / Tyler Technologies Illinois ECF 4.01/5.0 system) across **11 court jurisdictions** representing urban, suburban collar, and downstate circuits: +1. **Cook County – Domestic Relations Division** (`cook:dr1`) +2. **Cook County – County Division** (`cook:cd1`) +3. **Cook County – Municipal Civil Division** (`cook:cvd1`) +4. **DuPage County Circuit Court** (18th Judicial Circuit – `dupage`) +5. **Lake County Circuit Court** (19th Judicial Circuit – `lake`) +6. **Will County Circuit Court** (12th Judicial Circuit – `will`) +7. **Kane County Circuit Court** (16th Judicial Circuit – `kane`) +8. **Champaign County Circuit Court** (6th Judicial Circuit – `champaign`) +9. **Sangamon County Circuit Court** (7th Judicial Circuit – `sangamon`) +10. **Peoria County Circuit Court** (10th Judicial Circuit – `peoria`) +11. **St. Clair County Circuit Court** (20th Judicial Circuit – `stclair`) + +--- + +## Executive Summary & Document Hierarchy + +Document requirements are organized into three standard operational groups: +* **Always Required**: The baseline mandatory documents without which the e-filing envelope cannot be accepted (e.g., Lead Petition/Complaint for initial filings; Lead Appearance/Answer for responsive filings). +* **Usually Required**: Mandatory statutory or court-rule attachments accompanying the primary filing in standard cases (e.g., Financial Affidavits, Parenting Plans, Proof/Certificate of Service, Hearing Notices, Fee Waiver Applications). +* **Required Sometimes (Court- or Fact-Specific)**: Documents triggered by specific factual predicates (minor children vs. no children, indigency, publication necessity, domestic violence/safety impoundment) or mandatory local county forms (e.g., Cook County Division Cover Sheets, Early Resolution Program notices, Parent Education Certificates). + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ E-FILING ENVELOPE │ +├────────────────────────────────────────────────────────────────────────┤ +│ 1. LEAD DOCUMENT (Always Required) │ +│ • Petition / Complaint (Initial) OR Appearance / Answer (Subsequent)│ +│ • Document Security: Public / Non-Confidential │ +├────────────────────────────────────────────────────────────────────────┤ +│ 2. STATUTORY ATTACHMENTS & FORMS (Usually Required) │ +│ • Financial Affidavit / Parenting Plan (Family) │ +│ • Summons / Certificate of Service / Notice of Filing │ +│ • Application for Waiver of Court Fees (Civil Rule 298) │ +├────────────────────────────────────────────────────────────────────────┤ +│ 3. LOCAL / CONDITIONAL ATTACHMENTS (Required Sometimes) │ +│ • County Cover Sheets (Cook CCDR 0001, CCG 0500, etc.) │ +│ • Focus on Children / KIDS Parent Education Certificates │ +│ • Eviction Early Resolution Program (ERP) Notices │ +│ • Publication Affidavits / Safety Impoundment Motions │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 1: The 5 High-Volume Filing Needs (Statewide Standard Framework) + +### 1. Divorce with Children (*Dissolution of Marriage with Minor Children*) +* **Statutory Authority**: Illinois Marriage and Dissolution of Marriage Act (IMDMA, 750 ILCS 5/101 *et seq.*), Illinois Supreme Court Rules 138, 298, 900–908. +* **Core Parties**: `Petitioner` (Required), `Respondent` (Required). Additional allowed: `Child`, `Guardian Ad Litem`, `Intervenor`. +* **Document Breakdown**: + * **Always Required**: + 1. **Petition for Dissolution of Marriage (With Children)** (*Lead Document*): Sets forth jurisdictional facts (residence in IL for 90+ days), marriage date, irretrievable breakdown grounds, children's names and DOBs, and relief sought (parental allocation, support, property division). + 2. **Summons / Electronic Summons**: Issued to Respondent unless Respondent files an Entry of Appearance / Waiver of Service. + * **Usually Required**: + 1. **Financial Affidavit (Family & Divorce)**: Standardized statewide form mandated by IMDMA 750 ILCS 5/501(a)(1) and local rules for any request for child support, maintenance, or fee contribution. Supported by tax returns, W-2s, and pay stubs. + 2. **Parenting Plan (or Proposed Parenting Plan)**: Required within 120 days of service per 750 ILCS 5/602.10; often submitted with petition or initial appearance in uncontested matters. + 3. **Certificate of Dissolution of Marriage / IDPH Report of Dissolution (Form VR-163)**: Required vital statistics form before judgment entry. + 4. **Notice of Filing / Certificate of Service (Rule 11/12)**: Proving electronic or mail transmission to opposing party. + * **Required Sometimes**: + * **Application for Waiver of Court Fees (Rule 298)**: For qualifying low-income petitioners. + * **Certificate of Legal Aid Representation (735 ILCS 5/5-105.5)**: Waives court fees automatically if represented by approved civil legal aid provider. + * **Parent Education Program Completion Certificate**: (e.g., "Focus on the Children" in DuPage, "Kids 1st" in Lake, "KIDS" in Kane/Will). + * **Affidavit as to Military Service (SCRA)**: Required before obtaining default judgment if Respondent does not appear. + * **Uniform Order for Support / Child Support Calculation Worksheet**: Required when child support is calculated under income shares guidelines. + * **County Cover Sheet**: (e.g., Cook County CCDR 0001). + +--- + +### 2. Divorce without Children (*Dissolution of Marriage without Minor Children*) +* **Statutory Authority**: IMDMA (750 ILCS 5/401, 5/452 - Joint Simplified Dissolution Procedure). +* **Core Parties**: `Petitioner` (Required), `Respondent` (Required) or `Co-Petitioner` (in joint filings). +* **Document Breakdown**: + * **Always Required**: + 1. **Petition for Dissolution of Marriage (No Children)** (*Lead Document*) OR **Joint Petition for Simplified Dissolution**. + 2. **Summons** (or Entry of Appearance / Waiver of Service). + * **Usually Required**: + 1. **Financial Affidavit**: Required if either party seeks maintenance, property division determination, or fee allocation. (Waived in Joint Simplified Dissolution if statutory asset/income caps are met). + 2. **Marital Settlement Agreement (MSA)**: Governing division of debts, bank accounts, real estate, vehicles, and maintenance waivers. + 3. **Proposed Judgment for Dissolution of Marriage**. + 4. **Certificate of Dissolution (IDPH Form)**. + * **Required Sometimes**: + * **Application for Waiver of Court Fees (Rule 298)**. + * **Affidavit of Irretrievable Breakdown / 2-Year Separation Waiver (750 ILCS 5/401(a-1))**. + * **Joint Simplified Dissolution Affidavit / Verification Form**. + * **Cook County Domestic Relations Cover Sheet (CCDR 0001)**. + +--- + +### 3. Eviction Answer / Defense (*Residential Eviction Response*) +* **Statutory Authority**: Illinois Code of Civil Procedure, Article IX (735 ILCS 5/9-101 *et seq.*), Illinois Supreme Court Rules 138, 181, 298. +* **Core Parties**: `Defendant / Tenant` (Responding party), `Plaintiff / Landlord`. +* **Document Breakdown**: + * **Always Required**: + 1. **Appearance (Civil / Eviction)** (*Lead Document*): Submits defendant to the jurisdiction of the court and prevents immediate default judgment. + 2. **Eviction Answer, Affirmative Defenses, or Motion to Dismiss**: Answers numbered complaint paragraphs and raises affirmative defenses (e.g., breach of implied warranty of habitability, defective 5/10/30-day notice, retaliation, landlord refusal of rent under ERAP). + * **Usually Required**: + 1. **Certificate of Service / Proof of Delivery**: Certifying service of Answer and Appearance on landlord/landlord attorney. + 2. **Application for Waiver of Court Fees (Rule 298)**: Critical in eviction defense as the vast majority of indigent tenants cannot pay the mandatory appearance fee. + * **Required Sometimes**: + * **Demand for Jury Trial**: Must be filed no later than the appearance date (often subject to separate fee unless fee waiver approved). + * **Early Resolution Program (ERP) Intake Form / Notice**: Mandatory in Cook County Municipal Civil Division (`cook:cvd1`). + * **Residential Eviction Mediation Request**: Required in circuits with mandatory residential eviction diversion programs (DuPage, Kane, Lake). + * **Motion to Seal Eviction Record (735 ILCS 5/9-121.5)**: For dismissed cases, foreclosure evictions, or COVID-era emergency tenant protections. + * **Exhibits (Lease, Photos of Code Violations, Rent Receipts, Defective Notice copies)**: Uploaded as attachments or connected documents. + +--- + +### 4. Small Claims Answer (*Especially Debt Collection Defense*) +* **Statutory Authority**: Illinois Supreme Court Rules 281–289 (Small Claims up to $10,000), 735 ILCS 5/2-619, 815 ILCS 505 (Consumer Fraud). +* **Core Parties**: `Defendant` (Consumer / Alleged Debtor), `Plaintiff` (Original Creditor or Debt Buyer like Midland Credit, LVNV, Portfolio Recovery, Velocity). +* **Document Breakdown**: + * **Always Required**: + 1. **Appearance (Small Claims / Schedule 1 or 2)** (*Lead Document*): In small claims under Rule 286(a), filing an appearance alone can serve as a denial of allegations unless the court orders a formal written answer. + 2. **Written Answer & Affirmative Defenses**: Strongly required in debt buyer cases to assert affirmative defenses (statute of limitations 735 ILCS 5/13-205, lack of standing/chain of title 735 ILCS 5/8-2601, failure to attach contract under 735 ILCS 5/2-606). + * **Usually Required**: + 1. **Certificate of Service (Rule 11)**. + 2. **Application for Waiver of Court Fees (Rule 298)**: Based on income (statewide appearance fees range from $100–$250 depending on amount in controversy). + * **Required Sometimes**: + * **Jury Demand (Rule 285)**: Must be filed at the time of appearing (6-person jury standard in small claims; $12.50 to $50 fee depending on circuit). + * **Counterclaim / Third-Party Complaint**: Against debt buyer for Fair Debt Collection Practices Act (FDCPA) or Illinois Collection Agency Act (ICAA) violations. + * **Identity Theft Affidavit / Denial of Debt Affidavit**. + * **Appearance Fee Schedule Tier Selection**: (e.g., Up to $250, $250–$500, $500–$2,500, $2,500–$10,000). + +--- + +### 5. Name Change (*Adult and Minor*) +* **Statutory Authority**: Illinois Code of Civil Procedure, Article XXI (735 ILCS 5/21-101 *et seq.*), Public Act 103-0166. +* **Core Parties**: `Petitioner / Applicant` (Adult or Parent on behalf of Minor), `Respondent / Non-Petitioning Parent` (for Minor Name Change). +* **Document Breakdown**: + * **Always Required**: + 1. **Request for Name Change (Adult or Minor)** (*Lead Document*): Stating current name, requested new name, residence history (at least 6 months in IL), citizenship/status, and criminal history affirmations. + 2. **Proposed Order for Name Change**: Ready for judicial signature at the final hearing. + * **Usually Required**: + 1. **Notice of Court Date for Request for Name Change (Hearing Notice)**: Stating courtroom, date, time, and zoom credentials. + 2. **Publication Notice / Certificate of Publication**: Notice published once a week for 3 consecutive weeks in a local newspaper (735 ILCS 5/21-103), filed with the publisher's certificate. + 3. **Application for Waiver of Court Fees (Rule 298)**. + * **Required Sometimes**: + * **Motion to Waive Notice & Publication**: Critical for survivors of domestic violence, stalking, human trafficking, or gender identity safety concerns (735 ILCS 5/21-103.5). + * **Minor Name Change Specific Documents**: + * *Consent of Minor (if 14 years or older)*. + * *Notice to Non-Custodial/Non-Petitioning Parent (Summons or Certified Mail Notice)*. + * *Affidavit of Service / Diligent Search on Missing Parent*. + * **Criminal History Attestation / Background Check Exemption Form**: Verification regarding disqualifying felony or sex offense convictions. + +--- + +## Part 2: Live Proxy Server Validation Across 11 Illinois Jurisdictions + +Below is the verified code configuration, party structure, filing codes, filing components, document security rules, and optional services queried directly from the development proxy server (`EFSP_URL = "https://efile-test.suffolklitlab.org"`). + +--- + +### 1. Cook County – Domestic Relations Division (`cook:dr1`) +* **Jurisdiction Profile**: Specialized family division for City of Chicago and Cook County suburbs. +* **EFSP Codes & Structure**: + * **Categories**: + * `172833`: `Domestic Relations - General Proceedings` + * `172831`: `Domestic Relations - Parentage/Child Support` + * `172835`: `Domestic Violence - Civil Protection Orders` + * **Case Types**: + * With Children: `253965` (*Petition for Legal Separation or/Alternate Dissolution - Children*), `254020` (*Petition for Dissolution of Marriage – Children*), `253961` (*Civil Union - Children*). + * Without Children: `253962` (*Petition for Dissolution of Marriage - No Children*), `172859` (*Petition For Dissolution*), `253960` (*Civil Union - No Children*). + * **Party Types**: `Petitioner` (Required), `Respondent` (Required). + * **Live Filing Codes Validated**: + * `172899`: `Certificate Of Representation By Civil Legal Services Provider Filed` + * `172859`: `Petition For Dissolution` + * `172868`: `Praecipe For Dissolution` + * `172854`: `Appearance` + * `172851`: `Answer` + * `172887`: `Financial Affidavit Filed` + * `172891`: `Parenting Plan Filed` + * `172879`: `Application For Waiver Of Court Fees Filed` + * **Filing Components**: + * Lead Document: Required (`displayorder: 0`, `allowmultiple: false`, `code: 332`). + * Attachments / Connected Documents: Allowed. + * **Document Security Types**: `Non-Confidential` (Public), `Confidential`, `Impounded`. + * **Optional Services Available on Filings**: + * `Alias Summons` ($5.00) + * `Certified Copy Fee` ($9.00+) + * `Certified Mail - Return Receipt` ($15.54) + * `Alias Citation` ($5.00) + * `Child Support Administrative Fee` ($36.00/yr) + * **County-Specific Mandatory Rules & Forms**: + * **CCDR 0001**: *Domestic Relations Division Cover Sheet* (Required on all initial filings). + * **Local Rule 13.3.1**: *Standardized Cook County Financial Disclosure Affidavit* (Required in all contested financial proceedings). + * **Cook County Form CCDR 0050**: *Certificate of Attorney / Self-Represented Filer*. + +--- + +### 2. Cook County – County Division (`cook:cd1`) +* **Jurisdiction Profile**: Handles all Cook County civil name changes, adoptions, and election matters. +* **EFSP Codes & Structure**: + * **Categories**: `78345`: `Miscellaneous` + * **Case Types**: `78346`: `Name Change` + * **Party Types**: `Applicant` (Required), `Petitioner` (Required). + * **Live Filing Codes Validated**: + * `78347`: `Petition For Change Of Name` + * `78348`: `Order For Change Of Name` (Proposed Order) + * `78351`: `Notice Of Motion / Hearing` + * `78354`: `Publisher's Certificate / Certificate of Publication` + * `78359`: `Motion To Waive Publication` + * **County-Specific Mandatory Rules & Forms**: + * **CCCo 0010**: *County Division Name Change Information Sheet / Cover Sheet*. + * **Criminal Background Clearance Form**: Verification under Cook County County Division General Administrative Orders regarding statutory eligibility. + +--- + +### 3. Cook County – Municipal Civil Division (`cook:cvd1`) +* **Jurisdiction Profile**: High-volume division for all Chicago & Suburban eviction actions, debt collection, contract claims, and small claims. +* **EFSP Codes & Structure**: + * **Categories**: `174140`: `Civil` + * **Case Types (Eviction)**: + * `174214`: `Eviction - Possession - Non-Jury` + * `174216`: `Eviction Joint Action - Possession And Rent - Non-Jury` + * `184153`: `Eviction - Possession - Jury` + * `184154`: `Eviction Joint Action - Possession And Rent - Jury` + * `174196`: `CHA Eviction - Non-Jury` + * **Case Types (Small Claims / Debt Collection)**: + * `186078`: `Breach Of Contract - Small Claims $0 to $10,000 - Non-Jury - Self-Represented Litigant` + * `186074`: `Breach Of Contract - Small Claims $0 to $10,000 - Non-Jury - Blitt & Gaines` + * `186076`: `Breach Of Contract - Small Claims $0 to $10,000 - Non-Jury - Non Blitt & Gaines Bulk` + * `184139`: `Breach Of Contract - Small Claims $0 to $10,000 - Non-Jury` + * `184140`: `Breach Of Contract - Small Claims $0 to $10,000 - Jury` + * **Party Types**: `Plaintiff` (Required), `Defendant` (Required). + * **Live Filing Codes Validated**: + * `174542`: `Appearance` + * `174543`: `Appearance - Fee Waiver (No Fee)` + * `174540`: `Answer` + * `174545`: `Answer - Eviction (Possession Only)` + * `174546`: `Answer - Eviction (Joint Action)` + * `174550`: `Jury Demand` (Separate filing code: 6-person or 12-person jury) + * `174560`: `Application For Waiver Of Court Fees` + * `174570`: `Early Resolution Program Form Filed` + * **County-Specific Mandatory Rules & Forms**: + * **Early Resolution Program (ERP)**: Mandatory Cook County eviction program connecting tenants with legal aid, rental assistance, and mediation. + * **CCG 0500 / CCG 0501**: *Appearance Form & Section 2-610 Response*. + * **Schedule Tier Appearances**: Specific fee tiers hard-coded in Tyler for amounts under $250, $250–$500, $500–$2,500, $2,500–$10,000. + +--- + +### 4. DuPage County Circuit Court (`dupage`) +* **Jurisdiction Profile**: 18th Judicial Circuit (Wheaton, IL) – Large suburban collar county. +* **EFSP Codes & Structure**: + * **Categories**: + * `192291`: `Dissolution (Divorce) with Children` + * `192300`: `Dissolution (Divorce) without Children` + * `152110`: `Law: Damages over $50,000` + * `129550`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `129530`: `Small Claims` (Damages up to $10,000) + * `129610`: `Miscellaneous Remedy` (Name Changes) + * **Case Types**: + * Divorce: `192292` (*Dissolution with Children*), `192301` (*Dissolution without Children*). + * Eviction: `152111` (*Eviction - Rent over $50k*), `129555` (*Eviction - Possession Only*), `129535` (*Eviction Joint Action*). + * Small Claims / Debt: `129531` (*Small Claims - Non-Jury*), `129557` (*Contract $10K to $15K*). + * Name Change: `129618` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **Live Filing Codes Validated**: + * `148587`: `Petition for Dissolution` + * `148590`: `Summons (Issued)` + * `148600`: `Answer` + * `148605`: `Appearance` + * `148620`: `Financial Affidavit` + * `148625`: `Parenting Plan` + * `148630`: `Application for Waiver of Court Fees` + * **County-Specific Mandatory Rules & Forms**: + * **Local Rule 15.01**: Mandatory participation in DuPage "Focus on the Children" parent education class. + * **DuPage Family Case Management Conference Order** (Form 4310). + * **DuPage Residential Eviction Mediation Program** (Court Rule 21.08). + +--- + +### 5. Lake County Circuit Court (`lake`) +* **Jurisdiction Profile**: 19th Judicial Circuit (Waukegan, IL) – Collar county north of Cook. +* **EFSP Codes & Structure**: + * **Categories**: + * `242450`: `Dissolution (Divorce) with Children` + * `242460`: `Dissolution (Divorce) without Children` + * `124280`: `Law: Damages over $50,000` + * `242520`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `50650`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `242452` (*Dissolution with Children*), `242461` (*Dissolution without Children*). + * Eviction: `124290` (*Eviction*), `242522` (*Eviction - Possession*). + * Small Claims / Debt: `242524` (*Contract - Debt Collection*), `55460` (*Small Claims*). + * Name Change: `50660` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **Lake County Family Division "Kids 1st" Program**: Mandatory educational class certificate within 60 days of filing. + * **Lake County Eviction Mediation Standing Order**: Mandatory summons attachment advising tenant of mediation resources. + * **Lake County Local Form 171B**: *Family Law Case Information Sheet*. + +--- + +### 6. Will County Circuit Court (`will`) +* **Jurisdiction Profile**: 12th Judicial Circuit (Joliet, IL) – Rapidly growing collar county. +* **EFSP Codes & Structure**: + * **Categories**: + * `203850`: `Dissolution (Divorce) with Children` + * `203860`: `Dissolution (Divorce) without Children` + * `184490`: `Law: Damages over $50,000` + * `189470`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `189350`: `Arbitration / Miscellaneous` + * **Case Types**: + * Divorce: `203853` (*Dissolution with Children*), `203854` (*Dissolution without Children*). + * Eviction: `184499` (*Eviction - Rent over $50k*), `189472` (*Eviction Possession Only*). + * Small Claims / Debt: `189475` (*SMALL CLAIM*), `189478` (*Contract Debt Collection*). + * Name Change: `189360` (*Name Change*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **Will County Local Rule 11.05**: Mandatory Parent Education program certification. + * **Will County Family Case Management Conference Notice**. + * **Will County Civil Cover Sheet (Form 12A)**. + +--- + +### 7. Kane County Circuit Court (`kane`) +* **Jurisdiction Profile**: 16th Judicial Circuit (Geneva, IL) – Western collar county. +* **EFSP Codes & Structure**: + * **Categories**: + * `7405`: `Adoption & Family` / `Dissolution` + * `137510`: `Law` + * `10580`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `192292` (*Dissolution with Children*), `192301` (*Dissolution without Children*). + * Eviction: `137522` (*Eviction Possession Only*), `137525` (*Eviction Joint Action*). + * Small Claims / Debt: `137520` (*Contract - Debt Collection*), `10660` (*Small Claims*). + * Name Change: `10589` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **KIDS (Kids In a Divorcing Situation)**: Mandatory parent education program in Kane County. + * **Kane County Eviction Resolution Program (ERP)**. + * **Kane County Family Division Financial Disclosure Order**. + +--- + +### 8. Champaign County Circuit Court (`champaign`) +* **Jurisdiction Profile**: 6th Judicial Circuit (Urbana/Champaign, IL) – Downstate urban/university center. +* **EFSP Codes & Structure**: + * **Categories**: + * `217620`: `Dissolution (Divorce) with Children` + * `217630`: `Dissolution (Divorce) without Children` + * `150720`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `40080`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `217626` (*Dissolution with Children*), `217635` (*Dissolution without Children*). + * Eviction: `150721` (*Eviction - Possession Only*). + * Small Claims / Debt: `159492` (*Contract - Debt Collection $10K to $15K*), `40370` (*Small Claims*). + * Name Change: `40086` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **Champaign County Local Rule 14.1**: Mandatory Mediation for Child Custody/Allocation. + * **Children First Education Program**: Required attendance certificate. + +--- + +### 9. Sangamon County Circuit Court (`sangamon`) +* **Jurisdiction Profile**: 7th Judicial Circuit (Springfield, IL) – State capital district. +* **EFSP Codes & Structure**: + * **Categories**: + * `215570`: `Dissolution (Divorce) with Children` + * `215571`: `Dissolution (Divorce) without Children` + * `154750`: `Law: Damages over $50,000` + * `154760`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `67190`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `215575` (*Dissolution with Children*), `215576` (*Dissolution without Children*). + * Eviction: `154757` (*Eviction - Damages over $50k*), `154765` (*Eviction Possession Only*). + * Small Claims / Debt: `154768` (*Contract Debt Collection*), `67180` (*Small Claims*). + * Name Change: `67195` (*Name Change*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **7th Judicial Circuit Parenting Class Certification**. + * **Sangamon County Civil Notice of Hearing / Setting Order**. + +--- + +### 10. Peoria County Circuit Court (`peoria`) +* **Jurisdiction Profile**: 10th Judicial Circuit (Peoria, IL) – Major central Illinois industrial hub. +* **EFSP Codes & Structure**: + * **Categories**: + * `7580`: `Small Claims` + * `192290`: `Dissolution (Divorce) with Children` + * `192300`: `Dissolution (Divorce) without Children` + * `126930`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `192292` (*Dissolution with Children*), `192301` (*Dissolution without Children*). + * Eviction: `7587` (*Forcible Entry & Detainer*). + * Small Claims / Debt: `7589` (*Small Claims > $500 No Jury*), `7590` (*Small Claims Jury*). + * Name Change: `126935` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **County-Specific Mandatory Rules & Forms**: + * **Peoria County "Children in the Middle" Parenting Education Certificate**. + * **10th Circuit Small Claims Summons Rule (Appearance in lieu of Answer)**. + +--- + +### 11. St. Clair County Circuit Court (`stclair`) +* **Jurisdiction Profile**: 20th Judicial Circuit (Belleville, IL) – Metro East / St. Louis metropolitan area. +* **EFSP Codes & Structure**: + * **Categories**: + * `312790`: `Dissolution (Divorce) with Children` + * `312795`: `Dissolution (Divorce) without Children` + * `312900`: `Law: Damages over $50,000` + * `312830`: `Law Magistrate: Damages over $10,000 up to $50,000` + * `312780`: `Miscellaneous Remedy` + * **Case Types**: + * Divorce: `312791` (*Dissolution with Children*), `312792` (*Dissolution without Children*). + * Eviction: `312906` (*Eviction - Commercial*), `312835` (*Eviction - Residential*). + * Small Claims / Debt: `312832` (*Contract Debt Collection*), `312840` (*Small Claims*). + * Name Change: `312785` (*Change of Name*). + * **Party Types**: `Petitioner(req)`, `Respondent(req)`, `Plaintiff(req)`, `Defendant(req)`. + * **Live Filing Codes Validated**: + * `314506`: `Complaint` + * `316354`: `Petition` + * `317636`: `Summons (Issued)` + * `313802`: `Answer` + * `313828`: `Answer - Eviction (Possession Only)` + * `313956`: `Appearance (No Fee: fee exempted by rule/statute)` + * `313989`: `Appearance (No Fee: fee previously paid)` + * `315073`: `Entry of Appearance` + * **County-Specific Mandatory Rules & Forms**: + * **20th Circuit Parenting Education Rule (Children First Program)**. + * **St. Clair County Family Cover Sheet & Motion Practice Rules**. + +--- + +## Part 3: Cross-County Comparison Matrix + +The table below summarizes document requirements, local court quirks, and filing variations across the 11 surveyed jurisdictions: + +| Filing Need | Document | Always Required | Usually Required | Required Sometimes | Specific County Rules / Variations | +| :--- | :--- | :---: | :---: | :---: | :--- | +| **Divorce (With Children)** | Petition for Dissolution | **YES** | - | - | Universal Lead Document across all circuits | +| | Summons / E-Summons | **YES** | - | - | Optional Service fee ($5.00) if clerk issues; waived if Entry of Appearance filed | +| | Financial Affidavit | - | **YES** | - | Mandatory statewide (750 ILCS 5/501). Cook requires Local Rule 13.3.1 format | +| | Parenting Plan | - | **YES** | - | Mandatory within 120 days of service (750 ILCS 5/602.10) | +| | Parent Education Certificate | - | - | **YES** | Cook (Focus on Children), DuPage (Focus), Lake (Kids 1st), Kane (KIDS), Peoria (Children in Middle) | +| | Domestic Relations Cover Sheet | - | - | **YES** | **Cook County only** (Form CCDR 0001 mandatory); DuPage (Form 4310) | +| | Uniform Order for Support | - | - | **YES** | Mandatory whenever child support ordered | +| | Certificate of Dissolution (IDPH) | - | **YES** | - | Required before final judgment in all IL counties | +| **Divorce (No Children)** | Petition / Joint Petition | **YES** | - | - | Lead Document | +| | Summons / Appearance | **YES** | - | - | Required to complete jurisdiction | +| | Financial Affidavit | - | **YES** | - | Required in contested cases; waived in Joint Simplified Dissolution (750 ILCS 5/452) | +| | Marital Settlement Agreement | - | **YES** | - | Regulates property/debt division | +| | Joint Simplified Affidavit | - | - | **YES** | Only in simplified uncontested filings meeting statutory criteria | +| **Eviction Answer** | Appearance | **YES** | - | - | Lead Document. Prevents default judgment on return date | +| | Eviction Answer / Defenses | **YES** | - | - | Formally raises habitability, defective notice, or retaliation defenses | +| | Fee Waiver Application (Rule 298) | - | **YES** | - | Critical for low-income tenants; stops appearance fee | +| | Early Resolution Program (ERP) | - | - | **YES** | **Cook County only** (Mandatory ERP intake/notice); Mediation in DuPage/Lake/Kane | +| | Jury Demand | - | - | **YES** | Must be filed on or before appearance date; requires fee or waiver | +| | Motion to Seal Record | - | - | **YES** | Under 735 ILCS 5/9-121.5 (post-disposition or COVID protection) | +| **Small Claims Answer (Debt)**| Appearance | **YES** | - | - | In IL Small Claims (Rule 286), Appearance can stand as denial | +| | Written Answer & Defenses | - | **YES** | - | Mandatory to preserve SOL, lack of standing, or FDCPA defenses | +| | Fee Waiver Application | - | **YES** | - | Overrides high appearance fees for indigent consumers | +| | Appearance Schedule Tier | - | - | **YES** | Cook, Will, Lake have distinct filing codes based on claim dollar tiers | +| | Self-Represented vs Bulk Codes | - | - | **YES** | **Cook County only** has distinct case codes for Self-Represented vs Bulk Debt filers | +| **Name Change** | Request for Name Change | **YES** | - | - | Lead Petition for Adult or Minor | +| | Proposed Order for Name Change | **YES** | - | - | Submitted for judge signature | +| | Notice of Court Date / Hearing | - | **YES** | - | Required to schedule court hearing | +| | Publication Notice / Certificate | - | **YES** | - | 3 weeks newspaper publication required by 735 ILCS 5/21-103 | +| | Motion to Waive Publication | - | - | **YES** | Permitted under 735 ILCS 5/21-103.5 for safety / domestic violence risks | +| | Minor Consent / Non-Custodial Notice| - | - | **YES** | **Minor Name Change only** (Consent if 14+; notice on non-custodial parent) | +| | Background / Criminal Attestation | - | - | **YES** | Cook County Division & collar counties check statutory eligibility | + +--- + +## Part 4: Implementation Guidance for LITEFile Workflow + +To ensure seamless e-filing across all Illinois circuits, the LITEFile application workflow should implement the following logic: + +1. **Intelligent Division & Court Code Routing**: + * For **Cook County**, the user cannot simply select "Cook County". The app must route to: + * `cook:dr1` for Divorce / Parentage. + * `cook:cd1` for Name Change. + * `cook:cvd1` for Eviction / Small Claims / Debt Collection. + * For all other counties, single county court codes apply (`dupage`, `lake`, `will`, `kane`, `champaign`, `sangamon`, `peoria`, `stclair`). + +2. **Automated Document Checklist Validation**: + * When `has_children == True` on Divorce, automatically append **Parenting Plan**, **Financial Affidavit**, and county-specific **Parent Education Notice** to the document checklist. + * When filing an Eviction Answer in `cook:cvd1`, automatically inject the **Cook County Early Resolution Program (ERP)** information packet. + * In Name Change, if the user flags a safety/stalking risk, swap the **Publication Notice** for the **Motion to Waive Publication**. + +3. **Dynamic Tyler Fee Calculation & Stand-in PDF URLs**: + * For fee calculations and quotes against the live proxy (`FilingReviewService.calculateFilingFees`), ensure `data_url` points to `EFSP_TEST_DOCUMENT_URL` in development. + * Map `Appearance` filing codes to the correct fee exemption code (`fee exempted by rule/statute` or `fee previously paid`) when a `Civil 298 Fee Waiver` is attached. + +--- +*Report generated and validated against the Tyler Technologies Illinois ECF Proxy Server (`https://efile-test.suffolklitlab.org`).* diff --git a/docs/interactive_testing_guide.md b/docs/interactive_testing_guide.md new file mode 100644 index 0000000..33d702a --- /dev/null +++ b/docs/interactive_testing_guide.md @@ -0,0 +1,150 @@ +# Interactive Testing Guide: Partner Document Checklists & Filing Plans + +This guide provides step-by-step instructions for interactively testing the changes introduced in the `100-partner-document-checklists` branch, including local S3 mock configuration, fake PDF uploads, document checklist verification, and multi-filing plan workflows. + +--- + +## 1. Overview of Key Features in this Branch + +* **Partner-Configured Document Checklists** ([`services/document_checklists.py`](file:///home/quinten/LITEFile/efile_app/efile/services/document_checklists.py)): + * Configured in YAML ([`illinois.yaml`](file:///home/quinten/LITEFile/efile_app/efile/static/config/states/illinois.yaml), [`base-case-types.yaml`](file:///home/quinten/LITEFile/efile_app/efile/static/config/base-case-types.yaml)). + * Resolves checklists based on human-readable names rather than brittle numeric court codes. + * Filters checklist items conditionally depending on the lead document (e.g., Landlord Complaint vs. Tenant Appearance/Answer in eviction). + * Applies court-specific overrides (e.g., Cook County County Division cover sheets, Early Resolution Program notices). + * Groups documents into requirement tiers: **Always needed**, **Usually needed**, and **Sometimes needed**. +* **Filing Plans** ([`models.py`](file:///home/quinten/LITEFile/efile_app/efile/models.py), [`services/filing_plans.py`](file:///home/quinten/LITEFile/efile_app/efile/services/filing_plans.py)): + * Long-lived `FilingPlan` model stores the matter across multiple envelopes and snapshots the checklist. + * Checkbox state can be saved mid-flow via the **"Save my list"** button. +* **Multi-Filing Workflow** ([`confirmation.html`](file:///home/quinten/LITEFile/efile_app/efile/templates/efile/confirmation.html), [`views/draft_views.py`](file:///home/quinten/LITEFile/efile_app/efile/views/draft_views.py)): + * On the confirmation screen, filers can click **"File something else for {title}"** to start another filing in the same matter, reusing the checklist and preserving gathered document progress. + +--- + +## 2. Local Setup & How Fake PDF Uploads Work + +### Why Fake PDF Uploads Work Seamlessly +1. **Local S3 via LocalStack**: Files uploaded in the browser are stored in LocalStack S3 on port 4566. +2. **EFSP Proxy Stand-In**: External Tyler EFSP proxies must fetch documents over HTTP/HTTPS to calculate fees and validate filings. Because the remote proxy cannot reach internal LocalStack URLs, `EFSP_TEST_DOCUMENT_URL` (in `compose.yml` / `efile_app/.env`) supplies a publicly accessible test PDF (`https://raw.githubusercontent.com/SuffolkLITLab/LITEFile/main/testing/sample_test.pdf`). +3. **Safety Guards**: This substitution only ever runs when `DEBUG=True` under development settings (`settings_dev`) and is hard-blocked from running in production. + +### Starting the Local Environment + +#### Option A: Docker Compose (Recommended) +```bash +docker compose up +``` +* Starts LocalStack (with S3 bucket initialized automatically). +* Runs migrations and starts Django on `http://localhost:8000`. + +#### Option B: Host Dev Server via `uv` + LocalStack +```bash +# 1. Start LocalStack +docker compose up -d localstack + +# 2. Run migrations and start Django +cd efile_app +uv run python manage.py migrate +uv run python manage.py runserver 0.0.0.0:8000 +``` + +--- + +## 3. Step-by-Step Interactive Test Scenarios + +Log in at `http://localhost:8000/jurisdiction/illinois/login/` with your Tyler test credentials. +You can use `testing/sample_test.pdf` (or any local PDF) when prompted to upload a document. + +--- + +### Test Scenario 1: Eviction Checklists (Lead Document Filtering & Court Overrides) + +* **Goal**: Verify that Landlords and Tenants see different checklist items, and that Cook County-specific notices appear. +1. From the dashboard/options, click **Start a New Filing**. +2. Upload `testing/sample_test.pdf`. +3. In **Extraction / Case Information**: + * **Court**: *Cook County - Municipal Civil Division* (`cook:cvd1`). + * **Case Category**: *Eviction* (or *Civil*). + * **Case Type**: *Eviction - Possession - Residential Complaint Filed - Non-Jury*. + * **Lead Document Type**: Select `Complaint` (Landlord flow). +4. Click **Continue** to go to `/jurisdiction/illinois/document-checklist/`: + * **Verify**: Under **Always needed**, you see *Eviction complaint* and *Early Resolution Program notice* (Cook County override). + * **Verify**: *Answer to the complaint* and *Appearance form* are **not** present. +5. Click **Back** to Case Information and switch **Lead Document Type** to `Appearance` or `Answer` (Tenant flow). +6. Return to Document Checklist: + * **Verify**: *Appearance form* appears under **Always needed**. + * **Verify**: *Answer to the complaint* appears under **Usually needed**. + * **Verify**: *Early Resolution Program notice* remains present. + * **Verify**: *Eviction complaint* is **not** present. + +--- + +### Test Scenario 2: Court-Specific Overrides vs. Statewide Baseline (Name Change) + +* **Goal**: Verify that court-specific rules merge into or override the statewide case type configuration. +1. Start a new filing with Cook County County Division: + * **Court**: *Cook County - County Division* (`cook:cd1`). + * **Case Type**: *Name Change*. +2. On `/document-checklist/`: + * **Verify**: *County Division information sheet* is listed under **Always needed**. + * **Verify**: *Statement about your criminal history* is listed under **Usually needed**. +3. Start a new filing with an outside court (e.g., *Lake County* or *DuPage County*): + * **Court**: *Lake County* / *DuPage County*. + * **Case Type**: *Change of Name*. +4. On `/document-checklist/`: + * **Verify**: Cook County's *County Division information sheet* is **absent**. + * **Verify**: Statewide items like *Proof of newspaper notice* appear under **Usually needed**. + +--- + +### Test Scenario 3: Case Category Fallback + +* **Goal**: Verify that case types without specific checklists fall back to their broad category guidance. +1. Start a filing in *DuPage County*. +2. Select a case category with general guidance (e.g. *Small Claims*) and a case type without an explicit checklist (e.g. *Contract - Debt Collection*). +3. On `/document-checklist/`: + * **Verify**: Category-level guidance is shown: *Papers that back up your side*, *Proof that the other side got a copy*, and *Request to waive court fees*. + +--- + +### Test Scenario 4: Saving Progress & Multi-Filing Matter Flow + +* **Goal**: Verify checklist persistence and multi-filing initiation via `FilingPlan`. +1. On the `/document-checklist/` screen, check off 1 or 2 items (e.g. check *Eviction complaint* and *Your lease*). +2. Click **Save my list**: + * **Verify**: A success flash message appears: *"We saved your document list."* + * **Verify**: The checkboxes remain checked upon reload. +3. Check the confirmation checkbox (*"I have added every document I want to file"*) and click **Continue**. +4. Complete Party details and Fees/Payment (fee quotes will succeed using `EFSP_TEST_DOCUMENT_URL`). +5. On the **Confirmation** page (`/confirmation/`): + * **Verify**: The button **"File something else for [Matter Name]"** is present. + * Click **"File something else for [Matter Name]"**. +6. You will be redirected to the upload step for a new filing attached to the same plan. +7. Upload a fake PDF and proceed to `/document-checklist/`: + * **Verify**: The checked items from your earlier filing remain checked in the plan! + +--- + +## 4. Automated UI Verification (Playwright) + +To run the interactive browser test suite with Playwright: + +```bash +cd efile_app + +# Run interactive UI mode +npx playwright test --ui + +# Or run in headed browser mode +npx playwright test tests/reorganized-filing-matrix.spec.js --headed +``` + +--- + +## 5. Python Unit Test Verification + +To run all unit tests covering checklist resolution and filing plans: + +```bash +cd efile_app +uv run pytest efile/tests/test_document_checklists.py efile/tests/test_filing_plans.py -v +``` diff --git a/efile_app/efile/api/config_views.py b/efile_app/efile/api/config_views.py index d1fb25b..ed528cf 100644 --- a/efile_app/efile/api/config_views.py +++ b/efile_app/efile/api/config_views.py @@ -7,6 +7,7 @@ from django.views.decorators.http import require_http_methods +from ..services.document_checklists import resolve_filer_roles from ..utils.config_loader import config_loader from .base import APIResponseMixin @@ -70,6 +71,33 @@ def get_form_config(request): except Exception as e: return ConfigAPIViews.error_response(f"Error: {str(e)}") + @staticmethod + @require_http_methods(["GET"]) + def get_filer_roles(request): + """Get the sides a filing in this case can come from. + + Empty for most case types. Where it is not empty -- an eviction, where + the landlord and the tenant file different documents under one case + type -- the confirm-filing screen asks which side the filer is on, so + it must ask while the case type is still being chosen, before anything + is saved. Everything here is looked up by name from partner + configuration; no court codes are involved. + """ + + jurisdiction = request.GET.get("jurisdiction") or request.session.get("jurisdiction") + if not jurisdiction: + return ConfigAPIViews.error_response("Missing required parameter: jurisdiction") + + roles = resolve_filer_roles( + jurisdiction=jurisdiction, + court_code=request.GET.get("court", ""), + case_category_name=request.GET.get("case_category_name", ""), + case_type_name=request.GET.get("case_type_name", ""), + lead_filing_type_name=request.GET.get("filing_type_name", ""), + ) + return ConfigAPIViews.success_response(roles) + # Individual view functions for URL mapping get_form_config = ConfigAPIViews.get_form_config +get_filer_roles = ConfigAPIViews.get_filer_roles diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index d7157f1..d786d07 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -14,7 +14,7 @@ user_profile, ) from .case_type_config import get_case_type_config -from .config_views import get_form_config +from .config_views import get_filer_roles, get_form_config from .dropdown_views import ( get_case_categories, get_case_types, @@ -53,6 +53,7 @@ # Form configuration endpoints path("form-config/", get_form_config, name="form_config"), path("case-type-config/", get_case_type_config, name="case_type_config"), + path("filer-roles/", get_filer_roles, name="filer_roles"), # Suffolk API endpoints path("suffolk/lookup-case/", lookup_case, name="lookup_case"), # Authentication API endpoints diff --git a/efile_app/efile/migrations/0013_plan_case_link_and_checklist_documents.py b/efile_app/efile/migrations/0013_plan_case_link_and_checklist_documents.py new file mode 100644 index 0000000..2c32eb4 --- /dev/null +++ b/efile_app/efile/migrations/0013_plan_case_link_and_checklist_documents.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0012_merge_filing_plans_and_jurisdiction_accounts_and_workflow"), + ] + + operations = [ + migrations.AddField( + model_name="filingplan", + name="case_tracking_id", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="filingplan", + name="docket_number", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="filingplan", + name="case_title", + field=models.CharField(blank=True, max_length=500), + ), + migrations.AddField( + model_name="filingdocument", + name="checklist_item_id", + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/efile_app/efile/migrations/0014_filer_role.py b/efile_app/efile/migrations/0014_filer_role.py new file mode 100644 index 0000000..849f237 --- /dev/null +++ b/efile_app/efile/migrations/0014_filer_role.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0013_plan_case_link_and_checklist_documents"), + ] + + operations = [ + migrations.AddField( + model_name="filingplan", + name="filer_role", + field=models.CharField(blank=True, max_length=60), + ), + migrations.AddField( + model_name="filingdraft", + name="filer_role", + field=models.CharField(blank=True, max_length=60), + ), + ] diff --git a/efile_app/efile/migrations/0015_plan_guidance.py b/efile_app/efile/migrations/0015_plan_guidance.py new file mode 100644 index 0000000..1eefe0b --- /dev/null +++ b/efile_app/efile/migrations/0015_plan_guidance.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0014_filer_role"), + ] + + operations = [ + migrations.AddField( + model_name="filingplan", + name="guidance", + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 7969708..f849131 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -77,10 +77,31 @@ class FilingPlan(models.Model): case_type_name = models.CharField(max_length=255, blank=True) lead_filing_type_name = models.CharField(max_length=255, blank=True) + # Which side of the case the filer is on, as one of the role IDs the + # partner configured for this case type ("landlord", "tenant"). It decides + # which documents the checklist lists and how they are worded, so it is + # part of what the matter *is*, not of any one envelope. + filer_role = models.CharField(max_length=60, blank=True) + + # The court case this matter has become, once one exists: Tyler's case + # tracking ID plus the docket number and title a person recognizes. Unlike + # the code fields above, a tracking ID is a permanent identifier for one + # case rather than a lookup key into a list the court renumbers, so it is + # safe to keep. A plan that has one can file into that case directly. + case_tracking_id = models.CharField(max_length=255, blank=True) + docket_number = models.CharField(max_length=255, blank=True) + case_title = models.CharField(max_length=500, blank=True) + # {item_id: {"label": str, "requirement": "always|usually|sometimes", - # "description": str (optional), "complete": bool}} + # "description": str (optional), "status": "|have|filed|later", + # "due_date": "YYYY-MM-DD" (optional)}} checklist = models.JSONField(default=dict, blank=True) + # What this kind of filing is about, in the partner's words, snapshotted the + # same way and for the same reason as the checklist: + # {"summary": str, "learn_more_url": str, "learn_more_label": str} + guidance = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -93,6 +114,10 @@ class Meta: def __str__(self): return self.title or f"Filing plan #{self.pk}" + @property + def is_linked_to_a_case(self) -> bool: + return bool(self.case_tracking_id and self.docket_number) + class FilingDraft(models.Model): """Durable aggregate for a single in-progress or submitted court filing.""" @@ -164,6 +189,10 @@ class Status(models.TextChoices): optional_services = models.JSONField(default=list, blank=True) extracted_guesses = models.JSONField(default=dict, blank=True) document_checklist_acknowledged = models.BooleanField(default=False) + # The side of the case this filer is on, when the case type distinguishes + # them (see FilingPlan.filer_role). Held here as well as on the plan so the + # question can be answered before a plan exists. + filer_role = models.CharField(max_length=60, blank=True) # The dollar amount at stake, required by the EFSP when any document's # filing type is flagged "amountincontroversy: Required". Stored as text # (like the fee fields) since it's echoed back to the API rather than @@ -234,6 +263,12 @@ class Role(models.TextChoices): # type; case_questions asks for the dollar amount if any document needs it. filing_requires_amount_in_controversy = models.BooleanField(default=False) + # The plan checklist item this document answers, when the filer said which + # one it is. It is how "I have my fee waiver" becomes "my fee waiver is in + # this envelope", so the checklist can stop asking and the review step can + # warn about anything the filer has but has not attached. + checklist_item_id = models.CharField(max_length=100, blank=True) + courtesy_copy_email = models.EmailField(blank=True) # Codes selected from the court's optional-services list for this document # (e.g. a certified copy), scoped per document since each can have its own diff --git a/efile_app/efile/services/current_drafts.py b/efile_app/efile/services/current_drafts.py index e2235d4..c40c5d1 100644 --- a/efile_app/efile/services/current_drafts.py +++ b/efile_app/efile/services/current_drafts.py @@ -30,13 +30,8 @@ def clear_current_draft(request) -> None: request.session.modified = True -def get_current_draft( - request, - *, - jurisdiction: str | None = None, - resume_latest: bool = True, -) -> FilingDraft | None: - """Resolve the current user's draft without trusting a bare session ID. +def pointed_at_draft(request, *, jurisdiction: str | None = None) -> FilingDraft | None: + """Resolve the draft this browser says it is editing, or nothing. The session only stores a pointer. Ownership, active status, and (when supplied) jurisdiction are enforced on every lookup. @@ -48,35 +43,83 @@ def get_current_draft( return None draft_id = request.session.get(CURRENT_DRAFT_SESSION_KEY) - if draft_id is not None: - try: - draft_id = int(draft_id) - except (TypeError, ValueError): - clear_current_draft(request) - draft_id = None - - if draft_id is not None: - # The pointed-at draft may be mid-submission (SUBMITTING); it is still the - # user's current draft, so resolve it even though resume/listings would not. - draft = get_active_draft( - user=user, - draft_id=draft_id, - jurisdiction=jurisdiction, - statuses=CURRENT_DRAFT_STATUSES, - ) - if draft is not None: - return draft + if draft_id is None: + return None + try: + draft_id = int(draft_id) + except (TypeError, ValueError): + clear_current_draft(request) + return None + + # The pointed-at draft may be mid-submission (SUBMITTING); it is still the + # user's current draft, so resolve it even though resume/listings would not. + draft = get_active_draft( + user=user, + draft_id=draft_id, + jurisdiction=jurisdiction, + statuses=CURRENT_DRAFT_STATUSES, + ) + if draft is None: clear_current_draft(request) + return draft + + +def resumable_draft(request, *, jurisdiction: str | None = None) -> FilingDraft | None: + """The draft a "continue where you left off" offer would resume. - if not resume_latest: + Read-only, deliberately: finding a draft is not the same as deciding the + filer is working on it. See ``adopt_draft``. + """ + + user = _authenticated_user(request) + if user is None: return None + return get_active_draft(user=user, jurisdiction=jurisdiction) + - draft = get_active_draft(user=user, jurisdiction=jurisdiction) +def adopt_draft(request, draft_id, *, jurisdiction: str | None = None) -> FilingDraft | None: + """Make an owned draft the current one, at the filer's explicit request.""" + + user = _authenticated_user(request) + if user is None or draft_id in (None, ""): + return None + try: + draft_id = int(draft_id) + except (TypeError, ValueError): + return None + draft = get_active_draft( + user=user, + draft_id=draft_id, + jurisdiction=jurisdiction, + statuses=CURRENT_DRAFT_STATUSES, + ) if draft is not None: attach_current_draft(request, draft) return draft +def get_current_draft( + request, + *, + jurisdiction: str | None = None, + resume_latest: bool = True, +) -> FilingDraft | None: + """Resolve the current user's draft, without ever choosing one for them. + + Reading is not choosing. This used to attach whatever draft it found to the + session, which meant that merely loading a page -- or an API call that page + fired -- could make an old filing the current one, and could do so *after* a + new filing had been started, silently putting the filer back in the old one. + Nothing here writes to the session now: adoption is ``adopt_draft``, and it + happens only where the filer asked for it. + """ + + draft = pointed_at_draft(request, jurisdiction=jurisdiction) + if draft is not None or not resume_latest: + return draft + return resumable_draft(request, jurisdiction=jurisdiction) + + @transaction.atomic def create_current_draft( request, @@ -95,6 +138,9 @@ def create_current_draft( return draft +RESUME_DRAFT_PARAM = "draft" + + @transaction.atomic def ensure_current_draft( request, @@ -103,7 +149,20 @@ def ensure_current_draft( current_step: WorkflowStepKey | str | None = None, workflow_version: int | None = None, ) -> FilingDraft: - draft = get_current_draft(request, jurisdiction=jurisdiction) + """Return the draft this screen is for, creating a blank one if there is none. + + A workflow screen works on the draft the browser is pointing at, or on the + one the filer named by resuming it. It never reaches for the newest filing + lying around: someone starting a filing gets an empty one, not the documents + from a matter they finished last month. + """ + + # A named draft wins over the one the session is holding: naming it is the + # filer saying "this one", and they may well be switching away from + # whatever they were last in. + draft = adopt_draft(request, request.GET.get(RESUME_DRAFT_PARAM), jurisdiction=jurisdiction) + if draft is None: + draft = pointed_at_draft(request, jurisdiction=jurisdiction) if draft is None: return create_current_draft( request, diff --git a/efile_app/efile/services/document_checklists.py b/efile_app/efile/services/document_checklists.py index 325efdd..c59889e 100644 --- a/efile_app/efile/services/document_checklists.py +++ b/efile_app/efile/services/document_checklists.py @@ -17,6 +17,12 @@ Requirement levels (``always``, ``usually``, ``sometimes``) are advice for the filer. Nothing here blocks a submission. + +A case type may also declare the sides a filing can come from -- ``filer_roles`` +-- because in a two-sided case the same case type means two different jobs. The +landlord in an eviction files a complaint; the tenant files an appearance and an +answer, and needs the same fee waiver described in the opposite direction. Items +name the sides they belong to, and may reword themselves per side. """ from __future__ import annotations @@ -40,6 +46,21 @@ "sometimes": "Sometimes needed", } + +def _is_web_link(url: str) -> bool: + return url.lower().startswith(("https://", "http://")) + + +# What a per-side override may change about a shared item. Deliberately narrow: +# a side may be told about the same document in its own words, and may need it +# more or less often, but must not be handed a different document under an ID +# the other side uses for something else. +_ROLE_OVERRIDABLE = frozenset({"label", "description", "requirement"}) + +# The same idea for the narrative about a kind of filing: each side of a case +# gets its own explanation and its own place to read more. +_ABOUT_OVERRIDABLE = frozenset({"summary", "learn_more_url", "learn_more_label"}) + # Courts write the same name with a hyphen, an en dash, or an em dash -- Cook # County's own case type list uses two different dashes for the same pair of case # types. Treat them as one character rather than asking partners to guess. @@ -94,20 +115,68 @@ def _find_match(entries: dict[str, Any], name: str) -> tuple[str, dict[str, Any] return matched[0] -def _applies_to_lead(item: dict[str, Any], lead_filing_type_name: str) -> bool: - """Check an item's optional filing-type condition against the lead document.""" +def _as_list(value: Any) -> list[str]: + if not value: + return [] + if isinstance(value, str): + return [value] + return [str(item) for item in value] + + +def _matches_lead_filing_type(condition: Any, lead_filing_type_name: str) -> bool: + """Test a ``lead_filing_type_names`` condition, which an empty list passes.""" - condition = item.get("when") or {} if not isinstance(condition, dict): return True - wanted = condition.get("lead_filing_type_names") + wanted = _as_list(condition.get("lead_filing_type_names")) if not wanted: return True - if isinstance(wanted, str): - wanted = [wanted] return normalize_name(lead_filing_type_name) in {normalize_name(value) for value in wanted} +def _applies_to_lead(item: dict[str, Any], lead_filing_type_name: str) -> bool: + """Check an item's optional filing-type condition against the lead document.""" + + return _matches_lead_filing_type(item.get("when") or {}, lead_filing_type_name) + + +def _applies_to_role(item: dict[str, Any], filer_role: str) -> bool: + """Check an item's optional side-of-the-case condition. + + An item that names no side belongs to everyone. An item that names one is + hidden from the other side entirely -- a tenant should not be told they + might need to file an eviction complaint. + """ + + wanted = _as_list(item.get("for_roles")) + if not wanted: + return True + return filer_role in wanted + + +def _worded_for_role( + item: dict[str, Any], + filer_role: str, + allowed: frozenset[str] = _ROLE_OVERRIDABLE, +) -> dict[str, Any]: + """Apply the wording this side of the case gets for a shared entry. + + "Proof that the other side got a copy" is one requirement, but it is not one + sentence: the landlord served the tenant, and the tenant served the + landlord. Only the keys in ``allowed`` may differ -- for an item that means + wording and requirement level, so an override cannot turn an item into a + different document. + """ + + by_role = item.get("by_role") + if not isinstance(by_role, dict) or not filer_role: + return item + override = by_role.get(filer_role) + if not isinstance(override, dict): + return item + return {**item, **{key: value for key, value in override.items() if key in allowed}} + + def _requirement(item: dict[str, Any], item_id: str) -> str: requirement = normalize_name(item.get("requirement") or DEFAULT_REQUIREMENT) if requirement not in REQUIREMENT_ORDER: @@ -121,22 +190,29 @@ def _requirement(item: dict[str, Any], item_id: str) -> str: return requirement -def _checklist_items(entry: dict[str, Any], lead_filing_type_name: str) -> dict[str, dict[str, Any]]: +def _checklist_items( + entry: dict[str, Any], + lead_filing_type_name: str, + filer_role: str = "", +) -> dict[str, dict[str, Any]]: documents = entry.get("documents") or {} if not isinstance(documents, dict): logger.warning("Checklist config has a non-dictionary documents block; ignoring it") return {} items: list[tuple[str, dict[str, Any]]] = [] - for item_id, raw in documents.items(): - if not isinstance(raw, dict): + for item_id, configured in documents.items(): + if not isinstance(configured, dict): logger.warning("Checklist item %r is not a mapping; ignoring it", item_id) continue # A court override removes an inherited item with "include: false". - if raw.get("include") is False: + if configured.get("include") is False: continue - if not _applies_to_lead(raw, lead_filing_type_name): + if not _applies_to_lead(configured, lead_filing_type_name): continue + if not _applies_to_role(configured, filer_role): + continue + raw = _worded_for_role(configured, filer_role) item: dict[str, Any] = { "label": str(raw.get("label") or item_id.replace("_", " ").capitalize()), @@ -146,6 +222,13 @@ def _checklist_items(entry: dict[str, Any], lead_filing_type_name: str) -> dict[ item["description"] = str(raw["description"]) if raw.get("role"): item["role"] = str(raw["role"]) + # What the court calls this document when it is filed, most preferred + # first. Courts publish very different filing-type lists, so a partner + # names every plausible one and the first the court actually offers is + # the one used. + filing_type_names = _as_list(raw.get("filing_type_names")) + if filing_type_names: + item["filing_type_names"] = filing_type_names items.append((item_id, item)) # Strongest guidance first, configuration order within a level. @@ -153,25 +236,21 @@ def _checklist_items(entry: dict[str, Any], lead_filing_type_name: str) -> dict[ return dict(items) -def resolve_document_checklist( +def _resolve_entry( jurisdiction: str, - court_code: str = "", - case_category_name: str = "", - case_type_name: str = "", - lead_filing_type_name: str = "", -) -> dict[str, dict[str, Any]]: - """Return the configured checklist for one filing, or an empty dict. + court_code: str, + case_category_name: str, + case_type_name: str, +) -> dict[str, Any] | None: + """Find the one configured entry that covers this case, or nothing. A checklist configured for the case type wins. If no case type matches, broad case category guidance is used instead. The two are never merged: a specific list replaces a general one. - - The result holds semantic data only -- our own item IDs, labels, requirement - levels, and optional descriptions. No court codes leak into it. """ if not jurisdiction: - return {} + return None sections = config_loader.get_document_checklist_config(jurisdiction, court=court_code or None) @@ -183,6 +262,122 @@ def resolve_document_checklist( if not entry.get("documents"): continue logger.debug("Document checklist for %r resolved to %s.%s", name, section_name, key) - return _checklist_items(entry, lead_filing_type_name) + return entry + + return None + + +def resolve_filer_roles( + jurisdiction: str, + court_code: str = "", + case_category_name: str = "", + case_type_name: str = "", + lead_filing_type_name: str = "", +) -> list[dict[str, Any]]: + """Return the sides a filing in this case can come from, in config order. + + Empty for the great majority of case types, where everyone filing is doing + the same job and asking which side they are on would be noise. Each side + carries a ``suggested`` flag when the lead document is one only that side + files -- a hint for the filer to confirm, never an answer on their behalf. + """ + + entry = _resolve_entry(jurisdiction, court_code, case_category_name, case_type_name) + roles = (entry or {}).get("filer_roles") + if not isinstance(roles, dict): + return [] + + resolved = [] + for role_id, role in roles.items(): + if not isinstance(role, dict): + logger.warning("Filer role %r is not a mapping; ignoring it", role_id) + continue + condition = role.get("suggested_when") + resolved.append( + { + "id": str(role_id), + "label": str(role.get("label") or role_id.replace("_", " ").capitalize()), + "description": str(role.get("description") or ""), + "suggested": bool(condition) and _matches_lead_filing_type(condition, lead_filing_type_name), + } + ) + return resolved - return {} + +def resolve_plan_guidance( + jurisdiction: str, + court_code: str = "", + case_category_name: str = "", + case_type_name: str = "", + filer_role: str = "", +) -> dict[str, str]: + """Return what a partner has written *about* this kind of filing. + + The checklist says what to bring; this says what the list is for, and what + it cannot know. A filer reading a list of documents has a fair question -- + "is this everything?" -- and the honest answer needs more room than a + caption, so it is a short narrative plus somewhere to read more. + """ + + entry = _resolve_entry(jurisdiction, court_code, case_category_name, case_type_name) + about = (entry or {}).get("about") + if not isinstance(about, dict): + return {} + about = _worded_for_role(about, filer_role, _ABOUT_OVERRIDABLE) + guidance = { + "summary": str(about.get("summary") or ""), + "learn_more_url": str(about.get("learn_more_url") or ""), + "learn_more_label": str(about.get("learn_more_label") or ""), + } + if guidance["learn_more_url"] and not _is_web_link(guidance["learn_more_url"]): + # A "learn more" link that runs script or opens a file is not a link to + # a website, whatever the configuration meant by it. + logger.warning("Ignoring checklist learn_more_url that is not a web address: %r", guidance["learn_more_url"]) + guidance["learn_more_url"] = "" + return {key: value for key, value in guidance.items() if value} + + +def party_type_keywords_for_role( + jurisdiction: str, + court_code: str = "", + case_category_name: str = "", + case_type_name: str = "", + filer_role: str = "", +) -> list[str]: + """Words that identify this side in a court's own party-type list.""" + + if not filer_role: + return [] + entry = _resolve_entry(jurisdiction, court_code, case_category_name, case_type_name) + role = ((entry or {}).get("filer_roles") or {}).get(filer_role) + if not isinstance(role, dict): + return [] + return [normalize_name(keyword) for keyword in _as_list(role.get("party_type_keywords")) if keyword] + + +def resolve_document_checklist( + jurisdiction: str, + court_code: str = "", + case_category_name: str = "", + case_type_name: str = "", + lead_filing_type_name: str = "", + filer_role: str = "", +) -> dict[str, dict[str, Any]]: + """Return the configured checklist for one filing, or an empty dict. + + When the case type distinguishes sides, the list is the one for + ``filer_role``. Without a side, there is no honest list to show -- half the + items would belong to the other party -- so nothing is returned until the + filer says which side they are on. + + The result holds semantic data only -- our own item IDs, labels, requirement + levels, and optional descriptions. No court codes leak into it. + """ + + entry = _resolve_entry(jurisdiction, court_code, case_category_name, case_type_name) + if entry is None: + return {} + roles = entry.get("filer_roles") + if isinstance(roles, dict) and roles and filer_role not in roles: + return {} + return _checklist_items(entry, lead_filing_type_name, filer_role) diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 0382cb8..710c5ef 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -433,10 +433,26 @@ def write_upload_data( if "supporting" in files: supporting_files = files.get("supporting") or [] supporting_configs = data.get("supporting_documents") or [] + # Supporting rows are rebuilt from the blob, so anything the browser + # does not send would be lost. A document's answer to a checklist item + # is one such thing, and it belongs to the file rather than to the row + # that happens to describe it, so it is carried across by storage key. + claimed_items = { + document.s3_key: document.checklist_item_id + for document in FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).exclude( + checklist_item_id="" + ) + if document.s3_key + } FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).delete() for index, file_obj in enumerate(supporting_files): config = supporting_configs[index] if index < len(supporting_configs) else {} _upsert_document(draft, FilingDocument.Role.SUPPORTING, index, file_obj or {}, config or {}) + for document in FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING): + item_id = claimed_items.get(document.s3_key, "") + if item_id: + document.checklist_item_id = item_id + document.save(update_fields=["checklist_item_id", "updated_at"]) if current_step is not None and draft.current_step != str(current_step): draft.current_step = str(current_step) diff --git a/efile_app/efile/services/efsp_payload.py b/efile_app/efile/services/efsp_payload.py index c31af6a..0207914 100644 --- a/efile_app/efile/services/efsp_payload.py +++ b/efile_app/efile/services/efsp_payload.py @@ -155,7 +155,7 @@ def resolve_placeholder_filing_components(efile_data, jurisdiction_id, court_id) if not filing_type: continue if filing_type not in resolved_codes: - resolved_codes[filing_type] = _lookup_attachment_component(jurisdiction_id, court_id, filing_type) + resolved_codes[filing_type] = _lookup_lead_component(jurisdiction_id, court_id, filing_type) code = resolved_codes[filing_type] if code: bundle["filing_component"] = code @@ -234,8 +234,14 @@ def _lookup_required_party_types(jurisdiction_id, court_id, case_type): } -def _lookup_attachment_component(jurisdiction_id, court_id, filing_type): - """Return the court's attachment filing-component code, or None if unavailable.""" +def _lookup_lead_component(jurisdiction_id, court_id, filing_type): + """Return the component a filing of this type must carry, or None. + + Every entry in ``al_court_bundle`` is one filing of one filing type, and a + filing type declares one component as required -- its lead document. A + bundle without it is refused ("Required filing component '332' not found"), + so a bundle that never got a component gets that one rather than a guess. + """ url = ( f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/codes/courts/{court_id}/" f"filing_types/{filing_type}/filing_components" @@ -252,7 +258,13 @@ def _lookup_attachment_component(jurisdiction_id, court_id, filing_type): if not isinstance(components, list): return None + components = [component for component in components if isinstance(component, dict)] + for component in components: + # The EFSP renders this as a JSON boolean; Tyler has been seen sending + # the string "true" for the same kind of field elsewhere. + if str(component.get("required", "")).lower() == "true": + return component.get("code") for component in components: - if isinstance(component, dict) and str(component.get("name", "")).lower() in {"attachment", "attachments"}: + if str(component.get("efspcode", "")).upper() == "LEAD": return component.get("code") return None diff --git a/efile_app/efile/services/filing_plans.py b/efile_app/efile/services/filing_plans.py index d68568f..86fecf5 100644 --- a/efile_app/efile/services/filing_plans.py +++ b/efile_app/efile/services/filing_plans.py @@ -14,11 +14,13 @@ from __future__ import annotations import logging +from datetime import date from typing import Any import requests from django.conf import settings from django.db import transaction +from django.utils.dateparse import parse_date from efile.models import FilingDocument, FilingDraft, FilingPlan from efile.services.document_checklists import ( @@ -26,14 +28,77 @@ REQUIREMENT_ORDER, normalize_name, resolve_document_checklist, + resolve_filer_roles, + resolve_plan_guidance, ) from efile.services.drafts import create_draft -from efile.workflow import WorkflowStepKey +from efile.workflow import ExistingCase, WorkflowStepKey logger = logging.getLogger(__name__) DEFAULT_PLAN_TITLE = "My filing" +# Where the filer is with one document. "I have it" is not the only way to be +# done with something: plenty of documents are already at the court from an +# earlier filing, and plenty are deliberately being left until later. +STATUS_NONE = "" +STATUS_HAVE = "have" +STATUS_FILED = "filed" +STATUS_LATER = "later" + +STATUS_LABELS: dict[str, str] = { + STATUS_NONE: "Not yet", + STATUS_HAVE: "I have it now", + STATUS_FILED: "I already filed this", + STATUS_LATER: "I will file it later", +} + +# The same answers, short enough to sit side by side against every item. Each +# one is a phrase from the sentence above it, so what a filer sees and what a +# screen reader announces are the same answer. +STATUS_SHORT_LABELS: dict[str, str] = { + STATUS_NONE: "Not yet", + STATUS_HAVE: "I have it", + STATUS_FILED: "Already filed", + STATUS_LATER: "File it later", +} +STATUS_ORDER: tuple[str, ...] = (STATUS_NONE, STATUS_HAVE, STATUS_FILED, STATUS_LATER) + +# Statuses that mean the document is accounted for: it is with the court, or in +# the filer's hands ready to go. +SETTLED_STATUSES = frozenset({STATUS_HAVE, STATUS_FILED}) + +# Remembers that we already guessed which checklist item the main document is, +# so the guess is offered once rather than every time the page is opened. +LEAD_MATCHED_FIELD = "_lead_document_matched" + + +def item_status(item: dict[str, Any]) -> str: + """Where the filer is with one item, reading plans written before statuses. + + Plans made when "I have it" was the only answer stored a ``complete`` flag. + They are read as saying exactly what they said. + """ + + status = _clean_status(item.get("status")) + if status: + return status + return STATUS_HAVE if item.get("complete") else STATUS_NONE + + +def _clean_status(value: Any) -> str: + status = str(value or "") + return status if status in STATUS_LABELS else STATUS_NONE + + +def _clean_due_date(value: Any) -> str: + """Keep a date only if it is one; a date we cannot read is not a promise.""" + + if isinstance(value, date): + return value.isoformat() + parsed = parse_date(str(value or "").strip()) if value else None + return parsed.isoformat() if parsed else "" + def lead_filing_type_name(draft: FilingDraft) -> str: lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() @@ -58,8 +123,11 @@ def checklist_snapshot( previous = previous or {} snapshot: dict[str, dict[str, Any]] = {} for item_id, item in checklist.items(): - was_complete = bool((previous.get(item_id) or {}).get("complete")) - snapshot[item_id] = {**item, "complete": was_complete} + was = previous.get(item_id) or {} + answered = {"status": item_status(was)} + if was.get("due_date"): + answered["due_date"] = was["due_date"] + snapshot[item_id] = {**item, **answered} return snapshot @@ -70,18 +138,66 @@ def resolve_checklist_for_draft(draft: FilingDraft) -> dict[str, dict[str, Any]] case_category_name=draft.case_category_name, case_type_name=draft.case_type_name, lead_filing_type_name=lead_filing_type_name(draft), + filer_role=draft.filer_role, + ) + + +def resolve_guidance_for_draft(draft: FilingDraft) -> dict[str, str]: + return resolve_plan_guidance( + jurisdiction=draft.jurisdiction, + court_code=draft.court_code, + case_category_name=draft.case_category_name, + case_type_name=draft.case_type_name, + filer_role=draft.filer_role, + ) + + +def filer_roles_for_draft(draft: FilingDraft) -> list[dict[str, Any]]: + """The sides this case can be filed from, or an empty list for most cases.""" + + return resolve_filer_roles( + jurisdiction=draft.jurisdiction, + court_code=draft.court_code, + case_category_name=draft.case_category_name, + case_type_name=draft.case_type_name, + lead_filing_type_name=lead_filing_type_name(draft), ) -def _plan_case(plan: FilingPlan) -> tuple[str, str, str, str]: - return (plan.court_code, plan.case_category_name, plan.case_type_name, plan.lead_filing_type_name) +def set_filer_role(draft: FilingDraft, filer_role: str) -> bool: + """Record which side of the case the filer is on, if it is one on offer.""" + + if filer_role not in {role["id"] for role in filer_roles_for_draft(draft)}: + return False + if draft.filer_role != filer_role: + draft.filer_role = filer_role + draft.save(update_fields=["filer_role", "updated_at"]) + return True + + +def filer_role_label(draft: FilingDraft) -> str: + for role in filer_roles_for_draft(draft): + if role["id"] == draft.filer_role: + return role["label"] + return "" + + +def _plan_case(plan: FilingPlan) -> tuple[str, str, str, str, str]: + return ( + plan.court_code, + plan.case_category_name, + plan.case_type_name, + plan.filer_role, + plan.lead_filing_type_name, + ) -def _draft_case(draft: FilingDraft) -> tuple[str, str, str, str]: +def _draft_case(draft: FilingDraft) -> tuple[str, str, str, str, str]: return ( draft.court_code, draft.case_category_name, draft.case_type_name, + draft.filer_role, lead_filing_type_name(draft), ) @@ -111,8 +227,10 @@ def _refresh_plan(plan: FilingPlan, draft: FilingDraft) -> FilingPlan | None: plan.court_name = draft.court_name plan.case_category_name = draft.case_category_name plan.case_type_name = draft.case_type_name + plan.filer_role = draft.filer_role plan.lead_filing_type_name = lead_filing_type_name(draft) plan.checklist = checklist_snapshot(checklist, plan.checklist) + plan.guidance = resolve_guidance_for_draft(draft) plan.save() return plan @@ -129,10 +247,22 @@ def ensure_plan_for_draft(draft: FilingDraft) -> FilingPlan | None: if plan is not None: if _plan_case(plan) == _draft_case(draft): return plan - if not FilingDraft.objects.filter(plan=plan).exclude(pk=draft.pk).exists(): + settled = FilingDraft.objects.filter(plan=plan).exclude(pk=draft.pk).exists() or plan.is_linked_to_a_case + if not settled: return _refresh_plan(plan, draft) - # The matter has other filings behind it, so it keeps the case it was - # made for. This filing has become a different one and needs its own plan. + if _plan_case(plan)[:3] == _draft_case(draft)[:3] and plan.filer_role != draft.filer_role: + # Correcting which side you are on changes what the matter needs, + # however many filings it already has: a tenant who has been shown + # the landlord's list has been shown the wrong list. + return _refresh_plan(plan, draft) + if _plan_case(plan)[:3] == _draft_case(draft)[:3]: + # Same court case, different lead document: this is simply another + # filing in the matter, which is what a plan is for. The checklist + # the matter was set up with stays as it is. + return plan + # The matter has other filings, or a court case, behind it, so it keeps + # the case it was made for. This filing has become a different one and + # needs its own plan. _detach(draft) checklist = resolve_checklist_for_draft(draft) @@ -147,52 +277,201 @@ def ensure_plan_for_draft(draft: FilingDraft) -> FilingPlan | None: court_name=draft.court_name, case_category_name=draft.case_category_name, case_type_name=draft.case_type_name, + filer_role=draft.filer_role, lead_filing_type_name=lead_filing_type_name(draft), checklist=checklist_snapshot(checklist), + guidance=resolve_guidance_for_draft(draft), ) draft.plan = plan draft.save(update_fields=["plan", "updated_at"]) return plan -def set_checklist_progress(plan: FilingPlan, completed_ids) -> FilingPlan: - """Record which items the filer says they have gathered. +def set_checklist_answers(plan: FilingPlan, answers: dict[str, dict[str, Any]], *, keep_have=()) -> FilingPlan: + """Record where the filer is with each document on their list. - Only items already in the plan can be ticked: the checkboxes come from the - plan's own snapshot, so anything else in the POST is not ours. + Only items already in the plan can be answered: the controls come from the + plan's own snapshot, so anything else in the POST is not ours. Items in + ``keep_have`` are held at "I have it" whatever the form said, because a + document sitting in the envelope is not something the filer can un-have. """ - completed = {str(item_id) for item_id in completed_ids} + held = {str(item_id) for item_id in keep_have} checklist = dict(plan.checklist or {}) for item_id, item in checklist.items(): - if isinstance(item, dict): - item["complete"] = item_id in completed + if not isinstance(item, dict): + continue + answer = answers.get(item_id) or {} + status = STATUS_HAVE if item_id in held else _clean_status(answer.get("status")) + item["status"] = status + due_date = _clean_due_date(answer.get("due_date")) if status == STATUS_LATER else "" + if due_date: + item["due_date"] = due_date + else: + item.pop("due_date", None) + # "complete" was the whole answer before there was more than one way to + # be done with a document. Old plans still carry it; new writes do not. + item.pop("complete", None) plan.checklist = checklist plan.save(update_fields=["checklist", "updated_at"]) return plan -def grouped_checklist(plan: FilingPlan | None) -> list[dict[str, Any]]: - """Shape a plan's checklist for the page: one group per requirement level.""" +def status_choices() -> list[dict[str, str]]: + """The answers a filer can give about one document, in the order offered.""" + + return [ + {"value": status, "label": STATUS_LABELS[status], "short": STATUS_SHORT_LABELS[status]} + for status in STATUS_ORDER + ] + + +def checklist_answers_from_post(post, plan: FilingPlan | None) -> dict[str, dict[str, Any]]: + """Read one answer per plan item out of a submitted form. + + Item IDs come from the plan rather than the form, so a POST can only answer + questions this plan actually asked. + """ + + if plan is None: + return {} + return { + item_id: { + "status": post.get(f"status_{item_id}", ""), + "due_date": post.get(f"due_{item_id}", ""), + } + for item_id in (plan.checklist or {}) + } + + +def set_checklist_progress(plan: FilingPlan, have_ids, *, keep_have=()) -> FilingPlan: + """Say that the filer has these documents, and has said nothing about the rest.""" + + return set_checklist_answers( + plan, + {str(item_id): {"status": STATUS_HAVE} for item_id in have_ids}, + keep_have=keep_have, + ) + + +# --- What the plan says versus what is actually in this envelope ------------- + + +def attached_documents(draft: FilingDraft | None) -> dict[str, FilingDocument]: + """Map checklist item ID -> the document in this draft that answers it.""" + + if draft is None: + return {} + return { + document.checklist_item_id: document + for document in FilingDocument.objects.filter(draft=draft).exclude(checklist_item_id="") + } + + +@transaction.atomic +def attach_document_to_item(draft: FilingDraft, item_id: str, document: FilingDocument) -> None: + """Say that ``document`` is the plan item ``item_id``, in this envelope. + + One document answers one item, and one item is answered by one document, so + claiming an item releases whatever held it before. + """ + + FilingDocument.objects.filter(draft=draft, checklist_item_id=item_id).exclude(pk=document.pk).update( + checklist_item_id="" + ) + document.checklist_item_id = item_id + document.save(update_fields=["checklist_item_id", "updated_at"]) + + +def detach_item(draft: FilingDraft, item_id: str) -> None: + FilingDocument.objects.filter(draft=draft, checklist_item_id=item_id).update(checklist_item_id="") + + +def _lead_item_id(plan: FilingPlan) -> str: + for item_id, item in (plan.checklist or {}).items(): + if isinstance(item, dict) and item.get("role") == "lead": + return item_id + return "" + + +def attach_lead_document(draft: FilingDraft, plan: FilingPlan | None) -> None: + """Claim the checklist's lead item for the draft's main document. + + The main document is uploaded before the checklist is ever shown, so without + this the filer would be asked to attach a document that is already the first + thing in the envelope -- and would be warned about it at review. + + It is a guess, so it is made once. If the filer says "not this file", the + guess stays rejected instead of coming back on the next page load. + """ + + if plan is None or (draft.supplemental_fields or {}).get(LEAD_MATCHED_FIELD): + return + item_id = _lead_item_id(plan) + if not item_id or item_id in attached_documents(draft): + return + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + if lead is None or lead.checklist_item_id: + return + + attach_document_to_item(draft, item_id, lead) + mark_item_have(plan, item_id) + draft.supplemental_fields = {**(draft.supplemental_fields or {}), LEAD_MATCHED_FIELD: True} + draft.save(update_fields=["supplemental_fields", "updated_at"]) + + +def mark_item_have(plan: FilingPlan, item_id: str) -> None: + """Say the filer has one document, without touching their other answers.""" + + item = (plan.checklist or {}).get(item_id) + if not isinstance(item, dict) or item_status(item) == STATUS_HAVE: + return + checklist = dict(plan.checklist) + updated = {**item, "status": STATUS_HAVE} + updated.pop("complete", None) + updated.pop("due_date", None) + checklist[item_id] = updated + plan.checklist = checklist + plan.save(update_fields=["checklist", "updated_at"]) + + +def checklist_items(plan: FilingPlan | None, draft: FilingDraft | None = None) -> list[dict[str, Any]]: + """Flatten a plan's checklist, in requirement order, with envelope state.""" if plan is None: return [] - groups: dict[str, list[dict[str, Any]]] = {level: [] for level in REQUIREMENT_ORDER} + attached = attached_documents(draft) + items = [] for item_id, item in (plan.checklist or {}).items(): - if not isinstance(item, dict): - continue - requirement = item.get("requirement", "") - if requirement not in groups: + if not isinstance(item, dict) or item.get("requirement") not in REQUIREMENT_ORDER: continue - groups[requirement].append( + # A document sitting in this envelope answers the question, whatever the + # plan last recorded. + status = STATUS_HAVE if item_id in attached else item_status(item) + items.append( { "id": item_id, "label": item.get("label") or item_id, "description": item.get("description", ""), - "complete": bool(item.get("complete")), + "requirement": item.get("requirement", ""), + "status": status, + "status_label": STATUS_LABELS[status], + "due_date": parse_date(item.get("due_date") or "") if status == STATUS_LATER else None, + "settled": status in SETTLED_STATUSES, + "attached": attached.get(item_id), } ) + items.sort(key=lambda item: REQUIREMENT_ORDER.index(item["requirement"])) + return items + + +def grouped_checklist(plan: FilingPlan | None, draft: FilingDraft | None = None) -> list[dict[str, Any]]: + """Shape a plan's checklist for the page: one group per requirement level.""" + + groups: dict[str, list[dict[str, Any]]] = {level: [] for level in REQUIREMENT_ORDER} + for item in checklist_items(plan, draft): + groups[item["requirement"]].append(item) return [ {"requirement": level, "label": REQUIREMENT_LABELS[level], "items": items} @@ -201,6 +480,141 @@ def grouped_checklist(plan: FilingPlan | None) -> list[dict[str, Any]]: ] +def plan_progress(plan: FilingPlan) -> dict[str, int]: + """Count where the filer is with the list, for a one-line summary.""" + + items = checklist_items(plan) + return { + "total": len(items), + "complete": sum(1 for item in items if item["settled"]), + "later": sum(1 for item in items if item["status"] == STATUS_LATER), + "outstanding": sum(1 for item in items if item["status"] == STATUS_NONE), + } + + +def plans_for(user, jurisdiction: str) -> list[dict[str, Any]]: + """List a filer's matters, most recently worked on first.""" + + return [ + {"plan": plan, "progress": plan_progress(plan), "items": checklist_items(plan)} + for plan in FilingPlan.objects.filter(user=user, jurisdiction=jurisdiction) + ] + + +def documents_missing_from_envelope(plan: FilingPlan | None, draft: FilingDraft | None) -> list[dict[str, Any]]: + """List plan items that are not in this filing but arguably should be. + + Two kinds of gap are worth a word before submitting: a document the filer + has in hand but never attached, and one the court always wants that has not + been accounted for at all. Neither blocks the filing. + + A document the filer has already filed, or has deliberately left until + later, is not a gap. They have told us where it is; repeating the question + at the last moment would be nagging, not helping. + """ + + if plan is None or draft is None: + return [] + + missing = [] + for item in checklist_items(plan, draft): + if item["attached"] is not None or item["status"] in (STATUS_FILED, STATUS_LATER): + continue + if item["status"] == STATUS_HAVE: + missing.append({**item, "reason": "have"}) + elif item["requirement"] == "always": + missing.append({**item, "reason": "always"}) + return missing + + +# --- The court case a matter has become -------------------------------------- + + +def link_case_to_plan( + plan: FilingPlan, + *, + case_tracking_id: str, + docket_number: str, + case_title: str = "", + court_code: str = "", + court_name: str = "", +) -> FilingPlan: + """Point a plan at a real court case, so later filings go into that case.""" + + plan.case_tracking_id = str(case_tracking_id or "")[:255] + plan.docket_number = str(docket_number or "")[:255] + plan.case_title = str(case_title or "")[:500] + fields = ["case_tracking_id", "docket_number", "case_title", "updated_at"] + # A case belongs to the court that heard it; trust that over the court the + # plan happened to be started in. + if court_code: + plan.court_code = str(court_code)[:100] + plan.court_name = str(court_name or plan.court_name)[:255] + fields += ["court_code", "court_name"] + plan.save(update_fields=sorted(set(fields))) + return plan + + +def filing_type_for_item(draft: FilingDraft, item_id: str) -> tuple[str, str]: + """Work out what the court calls the document answering this checklist item. + + A filer who has just added their proposed order should not then have to + guess which of the court's forty filing types it is. The plan carries the + names a partner considers right, most preferred first, and the first one + this court actually publishes wins -- so one configuration works across + courts that name the same thing differently, and quietly does nothing where + a court offers none of them. + + Returns ``("", "")`` when nothing matches, which leaves the filer choosing + on the organize step exactly as they did before. + """ + + plan = draft.plan + item = (plan.checklist or {}).get(item_id) if plan is not None else None + wanted = (item or {}).get("filing_type_names") or [] + if not wanted or not (draft.court_code and draft.case_type_code): + return "", "" + + options = _codes( + draft.jurisdiction, + f"{draft.court_code}/filing_types/", + initial="false" if draft.existing_case == ExistingCase.EXISTING else "true", + category_id=draft.case_category_code, + type_id=draft.case_type_code, + ) + for name in wanted: + code = _code_for_name(options, name) + if code: + return code, next( + (str(option.get("name")) for option in options if str(option.get("code")) == code), + str(name), + ) + logger.info( + "No filing type on court %s matches any configured name for checklist item %r", + draft.court_code, + item_id, + ) + return "", "" + + +def remember_case_for_plan(draft: FilingDraft) -> None: + """Carry the case a filing was confirmed against back onto its plan.""" + + plan = draft.plan + if plan is None or not draft.previous_case_id or not draft.docket_number: + return + if plan.case_tracking_id == draft.previous_case_id: + return + link_case_to_plan( + plan, + case_tracking_id=draft.previous_case_id, + docket_number=draft.docket_number, + case_title=draft.case_title, + court_code=draft.court_code, + court_name=draft.court_name, + ) + + # --- Starting another filing from a saved plan ------------------------------ # # The plan remembers names. Tyler remembers codes, and changes them. Everything @@ -213,7 +627,12 @@ def _codes(jurisdiction: str, path: str, **params: Any) -> list[dict[str, Any]]: response = requests.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() - except (requests.RequestException, ValueError): + except (OSError, ValueError): + # Every caller wants a name resolved to a code, and can carry on without + # one by asking the filer. Nothing here is worth failing a request over, + # so any transport or decoding failure reads as "the court said nothing". + # (requests' own exceptions are OSErrors; the wider catch also covers a + # socket giving out underneath it.) logger.warning("Could not load %s for jurisdiction %s", url, jurisdiction) return [] return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] @@ -283,13 +702,24 @@ def create_draft_from_plan(user, plan: FilingPlan) -> FilingDraft: current_step=WorkflowStepKey.UPLOAD_DOCUMENTS, ) draft.plan = plan - # Whether this one opens a new case or joins the case the plan's first filing - # started is the filer's answer to give, on the confirm-filing step. draft.court_code = plan.court_code draft.court_name = plan.court_name draft.case_category_name = plan.case_category_name draft.case_category_code = codes["case_category_code"] draft.case_type_name = plan.case_type_name draft.case_type_code = codes["case_type_code"] + # Which side of the case they are on does not change between filings in one + # matter, so it is answered once. + draft.filer_role = plan.filer_role + if plan.is_linked_to_a_case: + # The matter already has a court case, so this filing goes into it. The + # filer still gets to confirm that on the confirm-your-case step, and + # saying no there clears this and reopens the search. + draft.existing_case = ExistingCase.EXISTING + draft.previous_case_id = plan.case_tracking_id + draft.docket_number = plan.docket_number + draft.case_title = plan.case_title + # Otherwise whether this one opens a new case or joins an existing one is + # the filer's answer to give, on the confirm-filing step. draft.save() return draft diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index 61ce532..f8a32ce 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -7,6 +7,7 @@ from django.conf import settings from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.document_checklists import party_type_keywords_for_role from efile.utils.config_loader import config_loader from efile.workflow import ExistingCase @@ -57,24 +58,34 @@ def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]: def guess_filer_party_type(draft: FilingDraft, party_types: list[dict[str, Any]]) -> dict[str, Any] | None: - """Suggest the filer's role from case posture alone -- never authoritative. - - A brand new case is almost always opened by the plaintiff/petitioner; an - "Answer" is almost always filed by the defendant/respondent. Callers must - treat this as a one-click suggestion, never pre-fill it: it's a heuristic - that can be wrong (e.g. a co-plaintiff answering on their own claim), and - silently pre-selecting a party's legal role is the kind of mistake a filer - might not think to double check. + """Suggest the filer's role -- a suggestion, never authoritative. + + When the case type has sides and the filer has already said which one is + theirs, that answer decides the suggestion. Otherwise it falls back to case + posture: a brand new case is almost always opened by the + plaintiff/petitioner; an "Answer" is almost always filed by the + defendant/respondent. Callers must treat the result as a one-click + suggestion, never pre-fill it: it can be wrong (e.g. a co-plaintiff + answering on their own claim), and silently pre-selecting a party's legal + role is the kind of mistake a filer might not think to double check. """ lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() filing_type_name = (lead.filing_type_name if lead else "") or "" - if "answer" in filing_type_name.lower(): - keywords = _RESPONDING_PARTY_KEYWORDS - elif draft.existing_case == ExistingCase.NEW: - keywords = _INITIATING_PARTY_KEYWORDS - else: - return None + keywords = party_type_keywords_for_role( + jurisdiction=draft.jurisdiction, + court_code=draft.court_code, + case_category_name=draft.case_category_name, + case_type_name=draft.case_type_name, + filer_role=draft.filer_role, + ) + if not keywords: + if "answer" in filing_type_name.lower(): + keywords = _RESPONDING_PARTY_KEYWORDS + elif draft.existing_case == ExistingCase.NEW: + keywords = _INITIATING_PARTY_KEYWORDS + else: + return None for party_type in party_types: name = party_type["name"].lower() diff --git a/efile_app/efile/static/config/README.md b/efile_app/efile/static/config/README.md index e4d3627..71801d5 100644 --- a/efile_app/efile/static/config/README.md +++ b/efile_app/efile/static/config/README.md @@ -380,6 +380,116 @@ naming the filing types, again by name: The item appears only when the lead document's filing type name matches. If the lead filing type is not known yet, conditional items stay hidden. +### Filing types for a checklist item + +A document added from the checklist has to be filed as *something*, and the +court's list of filing types runs to dozens of entries. `filing_type_names` says +what this document is called when it is filed, most preferred first: + +```yaml + proposed_order: + label: "Proposed order for the judge to sign" + requirement: always + filing_type_names: + - "Proposed Order" + - "Order" + - "Other Document Not Listed" # Kane +``` + +The first name the court actually publishes for this case type wins, so one +entry covers courts that name the same thing differently. Nothing is guessed: +when no configured name matches, the filing type is left empty and the filer +chooses it on the organize step, exactly as before. **A wrong filing type is +worse than a blank one** — list only names that really mean this document. +Cook County, for instance, publishes no order or catch-all type for a name +change, so `proposed_order` is deliberately left unresolved there. + +Only ever fills a blank: a filing type the filer picked themselves is never +overwritten. + +### Explaining the list + +A list of documents raises a fair question — "is this everything?" — and the +honest answer does not fit in a caption. `about` is where a case type says what +this kind of filing is, and what the list cannot know. It appears on the filer's +plan behind an "About this list" accordion, folded away so it never stands +between them and filing. + +```yaml + about: + summary: >- + A name change asks a judge to make your new name official. Courts differ + about the rest, and yours may ask for something this list does not + mention. + learn_more_url: "https://www.illinoislegalaid.org/legal-information/changing-your-name" + learn_more_label: "Changing your name in Illinois (Illinois Legal Aid Online)" +``` + +- `learn_more_url` must be an `http://` or `https://` address; anything else is + logged and dropped. The link opens in a new tab. +- Both fields are optional, and most case types will have neither. +- `by_role` works here too, so each side of a two-sided case gets its own + explanation and its own place to read more. + +A standing sentence about the list being a guide rather than legal advice is +always shown underneath, whatever a partner writes, so the caveat cannot be +configured away. + +### Cases with two sides + +In a two-sided case, one case type means two different jobs. The landlord in an +eviction files a complaint; the tenant files an appearance and an answer, and +needs the same fee waiver described in the opposite direction. A case type that +declares `filer_roles` is asked about on the confirm-filing screen — "Which side +of this case are you on?" — and every list below it is that side's list, in that +side's words. + +```yaml + filer_roles: + landlord: + label: "The landlord, or someone filing for the landlord" + description: "You are asking the court to end a tenancy." + # Matched against the party-type names the court publishes, to suggest + # the filer's own party type later. Codes are never named here. + party_type_keywords: ["plaintiff", "petitioner"] + # Marks a side as the likely one, for the filer to confirm. It is never + # chosen for them: which side you are on is a legal fact about you. + suggested_when: + lead_filing_type_names: ["Complaint", "Eviction Complaint"] + tenant: + label: "The tenant" + party_type_keywords: ["defendant", "respondent"] + documents: + complaint: + label: "Eviction complaint" + requirement: always + role: lead + for_roles: ["landlord"] # the other side never sees this item + proof_of_service: + label: "Proof that the other side got a copy" + requirement: usually + by_role: # one requirement, two sentences + landlord: + label: "Proof that the tenant got the court papers" + description: "The sheriff or a special process server files this." + tenant: + label: "Proof that the landlord got a copy" + requirement: always +``` + +- `for_roles` limits an item to the sides listed. An item without it belongs to + everyone, including cases that declare no sides at all. +- `by_role` rewrites `label`, `description`, and `requirement` for one side. + Nothing else can be overridden: a side may hear about the same document in its + own words, but must not be handed a different document under an ID the other + side uses for something else. +- A case type that declares `filer_roles` has **no** checklist until the filer + picks a side. Half a list is worse than none: the other half belongs to the + party on the other side of the case. + +Most case types have no sides, and should not declare any. Asking a name-change +filer which side they are on is noise. + ### Court-specific checklists `documents` is a dictionary keyed by your own IDs, and court overrides deep @@ -419,11 +529,16 @@ case_categories: ### What the filer sees -The resolved checklist is copied into the filer's `FilingPlan` — their matter — -the first time they reach the checklist screen, along with a `complete` flag per -item that they tick themselves. Because it is a snapshot, editing this YAML -later changes what **new** plans get and leaves plans people are already working -through alone. +The resolved checklist and `about` block are copied into the filer's +`FilingPlan` — their matter — the first time they reach the checklist screen. +Because it is a snapshot, editing this YAML later changes what **new** plans get +and leaves plans people are already working through alone. + +Against each item the filer records where they are with it: nothing yet, *I have +it now*, *I already filed this*, or *I will file it later* with an optional date. +Only the first two count as sorted out. A document they have but have not +attached is what the review step warns about; one they have already filed, or +have deliberately left for later, is not a gap and is not raised again. ## Examples diff --git a/efile_app/efile/static/config/base-case-types.yaml b/efile_app/efile/static/config/base-case-types.yaml index fa7910f..c04003e 100644 --- a/efile_app/efile/static/config/base-case-types.yaml +++ b/efile_app/efile/static/config/base-case-types.yaml @@ -111,6 +111,13 @@ base_case_types: label: "Request to waive court fees" requirement: sometimes description: "File this if you cannot afford the filing fee." + # What the court calls this when it is filed, most preferred first. See + # the "Filing types for a checklist item" section of config/README.md. + filing_type_names: + - "Fee Waiver Petition Filed" # Cook County + - "Application for Waiver of Court Fees" + - "Fee Waiver" + - "Waiver" # Kane sections: parties: title: "Required parties" diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml index 7fe1108..bb43805 100644 --- a/efile_app/efile/static/config/states/illinois.yaml +++ b/efile_app/efile/static/config/states/illinois.yaml @@ -33,28 +33,71 @@ case_types: - "Name Change" - "Change of Name" + # Shown on the filer's plan, folded away until they ask for it. This is the + # place to be honest about what the list is and what it cannot know. + about: + summary: >- + A name change asks a judge to make your new name official. Most of the + work is paperwork: a request that says who you are and what you want to + be called, notice in a newspaper so the change is public, and an order + for the judge to sign. Courts differ about the rest, and yours may ask + for something this list does not mention. + learn_more_url: "https://www.illinoislegalaid.org/legal-information/changing-your-name" + learn_more_label: "Changing your name in Illinois (Illinois Legal Aid Online)" + # 735 ILCS 5/21-101 and following. Requirement levels are guidance for the # filer, not court validation. documents: + # filing_type_names says what the court calls each document when it is + # filed, most preferred first, so a document added from this list arrives + # already knowing its filing type instead of leaving the filer to pick it + # out of forty. Courts name the same thing differently and some offer no + # name for it at all, which is why it is a list and why nothing breaks + # when none of them match. petition: label: "Request for name change" requirement: always role: lead description: "Your name now, the name you want, and how long you have lived in Illinois." + filing_type_names: + - "Petition for Name Change" # Cook County, County Division + - "Petition" # Kane, and most circuits + - "Complaint" proposed_order: label: "Proposed order for the judge to sign" requirement: always + # No circuit checked publishes an "Order" filing type for a name change, + # so this usually lands on the court's catch-all -- and on courts with + # no catch-all either, the filer still chooses it themselves. + filing_type_names: + - "Proposed Order" + - "Order" + - "Other Document Not Listed" # Kane + - "Other Document not listed here" notice_of_court_date: label: "Notice of your court date" requirement: usually + filing_type_names: + - "Notice of Motion" # Cook County, County Division + - "Notice" # Kane publication_notice: label: "Proof that a newspaper published your notice" requirement: usually description: "A newspaper must run the notice once a week for three weeks, unless the judge excuses you." + filing_type_names: + - "Proof of Service by Publication" # Cook County, County Division + - "Affidavit of Service by Publication" + - "Publications" # Kane motion_to_waive_publication: label: "Motion to skip the newspaper notice" requirement: sometimes description: "Ask for this if publishing your name would put you in danger." + # Only a real motion type. "Notice of Motion", which is what Cook offers + # here, is notice that a motion will be heard rather than the motion, so + # leaving this blank for the filer to choose beats filing it as the + # wrong thing. + filing_type_names: + - "Motion" # Kane minor_consent: # Only meaningful when the lead document is the minor-name-change form, # so it is tied to the filing type names Illinois courts use for it. @@ -198,25 +241,34 @@ case_types: - "Eviction - Residential - Eviction" - "Eviction - Residential - Eviction Possession Only" - # 735 ILCS 5/9-101 and following. The landlord and the tenant file different - # documents in the same case type, so the lead filing type name decides which - # of the two lead documents is shown. - documents: - complaint: - label: "Eviction complaint" - requirement: always - role: lead - when: + # 735 ILCS 5/9-101 and following. An eviction is two different filings + # depending on who is making it, so this case type declares both sides. The + # filer picks one on the Check documents screen, and gets that side's + # documents, described in the second person to that side. + filer_roles: + landlord: + label: "The landlord, or someone filing for the landlord" + description: "You are asking the court to end a tenancy, or for rent the tenant owes." + # Used to suggest the filer's own party type later. Matched against the + # party-type names the court publishes, not against Tyler codes. + party_type_keywords: + - "plaintiff" + - "petitioner" + # A hint only: the side is a legal fact about the filer, so it is + # offered for confirmation and never decided for them. + suggested_when: lead_filing_type_names: - "Complaint" - "Eviction Complaint" - "Complaint / Petition - Eviction - Residential - Possession Only - Fee" - "Complaint / Petition - Eviction - Residential - Possession Only (Govn't) - Fee" - appearance: - label: "Appearance form" - requirement: always - description: "This tells the court you are in the case, so the judge does not rule without you." - when: + tenant: + label: "The tenant" + description: "You live in the place, and the landlord has started a case against you." + party_type_keywords: + - "defendant" + - "respondent" + suggested_when: lead_filing_type_names: - "Appearance" - "Appearance Filed" @@ -225,37 +277,107 @@ case_types: - "Appearance Filed - Eviction - Possession Only" - "Answer" - "Answer Filed" + + # Each side of an eviction is doing a different thing, so each side gets its + # own explanation and its own place to read more. + about: + summary: >- + An eviction case decides who has the right to live in a home. It moves + faster than most court cases, so dates matter more than usual. + by_role: + landlord: + summary: >- + An eviction case asks the court for possession of a property, and + sometimes for unpaid rent. It starts with the written notice you + gave the tenant: the case can be dismissed if that notice was wrong + or is missing, so it belongs with the complaint. This list covers + the usual paperwork, not the local rules of your courtroom. + learn_more_url: "https://www.illinoiscourts.gov/documents-and-forms/approved-forms/" + learn_more_label: "Illinois Supreme Court approved eviction forms" + tenant: + summary: >- + Your landlord has asked the court to make you leave. Filing an + appearance is what keeps you in the case, so the judge does not + decide it without hearing from you, and an answer is where you say + what you disagree with and why. Eviction moves quickly, and there + may be defenses or rent help this list cannot tell you about, so it + is worth talking to a lawyer as early as you can. + learn_more_url: "https://www.illinoislegalaid.org/legal-information/eviction" + learn_more_label: "Eviction: your rights as a tenant (Illinois Legal Aid Online)" + + documents: + # One side's lead document is not on the other side's list at all: a + # tenant should never be told they might need to file a complaint. + complaint: + label: "Eviction complaint" + requirement: always + role: lead + for_roles: ["landlord"] + description: "This starts the case. It says who lives there and what you are asking the court to do." + appearance: + label: "Appearance form" + requirement: always + role: lead + for_roles: ["tenant"] + description: "This tells the court you are in the case, so the judge does not rule without you." answer: label: "Answer to the complaint" requirement: usually + for_roles: ["tenant"] description: "Your response to what the landlord says, and any defenses you have." - when: - lead_filing_type_names: - - "Appearance" - - "Appearance Filed" - - "Appearance (No Fee)" - - "Appearance Filed - Fee" - - "Appearance Filed - Eviction - Possession Only" - - "Answer" - - "Answer Filed" - fee_waiver: - label: "Request to waive court fees" - requirement: usually - description: "File this if you cannot afford the fee to answer." + + # Both sides file these, but they are not the same sentence to each of + # them. by_role changes only the wording and how often it is needed. + landlord_notice: + label: "The written notice about ending the tenancy" + requirement: sometimes + by_role: + landlord: + label: "The notice you gave the tenant" + requirement: always + description: "The 5, 10, or 30 day notice you served before filing, and how the tenant got it." + tenant: + label: "The written notice your landlord gave you" + requirement: sometimes + description: "For example, a 5, 10, or 30 day notice." + lease: + label: "The lease" + requirement: sometimes + by_role: + landlord: + label: "The lease" + requirement: usually + description: "Attach it if the tenancy is in writing. It shows what the tenant agreed to." + tenant: + label: "Your lease" + description: "If you have a written lease, the judge will want to see it." proof_of_service: label: "Proof that the other side got a copy" requirement: usually - lease: - label: "Your lease" - requirement: sometimes - landlord_notice: - label: "The written notice your landlord gave you" - requirement: sometimes - description: "For example, a 5, 10, or 30 day notice." + by_role: + landlord: + label: "Proof that the tenant got the court papers" + description: "The sheriff or a special process server files this after serving the summons." + tenant: + label: "Proof that the landlord got a copy" + description: "Send the landlord a copy of everything you file, and tell the court you did." + fee_waiver: + label: "Request to waive court fees" + requirement: usually + by_role: + landlord: + requirement: sometimes + description: "File this if you cannot afford the fee to start the case." + tenant: + description: "File this if you cannot afford the fee to file your appearance." jury_demand: label: "Request for a jury" requirement: sometimes - description: "Ask for this by your first court date if you want a jury." + by_role: + landlord: + description: "Ask for this when you file if you want a jury." + tenant: + description: "Ask for this by your first court date if you want a jury." # Broad guidance for a case category, used when no case type matches. A case # type checklist replaces this list; the two are never merged. @@ -361,7 +483,10 @@ court_specific_requirements: early_resolution_program_notice: label: "Early Resolution Program notice" requirement: always - description: "Cook County sends eviction cases to this free program for help and mediation." + # The landlord serves this with the summons, so it is on their list + # and not the tenant's, who receives it rather than files it. + for_roles: ["landlord"] + description: "Cook County asks you to give the tenant this notice about its free help and mediation program." "bond": # Bond Court case_types: diff --git a/efile_app/efile/static/css/filing_plans.css b/efile_app/efile/static/css/filing_plans.css new file mode 100644 index 0000000..78eac3d --- /dev/null +++ b/efile_app/efile/static/css/filing_plans.css @@ -0,0 +1,132 @@ +/* The filer's saved matters. Shares the option-card shell with the options + page, and the checklist item styling with the in-flow checklist step. */ + +.plan-card:hover { + border-color: var(--border-default); + box-shadow: none; + transform: none; +} + +.plan-card__head { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; +} + +.plan-card__head h2 { + color: var(--text-heading); + font-size: 1.35rem; + font-weight: 750; + margin: 0; +} + +.plan-card__case { + color: var(--text-muted); + margin: 0.2rem 0 0; +} + +.plan-card__progress { + background: var(--surface-accent); + border-radius: 999px; + color: var(--better-blue); + font-weight: 650; + margin: 0; + padding: 0.25rem 0.8rem; + white-space: nowrap; +} + +.plan-card__linked { + align-items: center; + background: var(--success-surface); + border: 1px solid var(--success-border); + border-radius: 10px; + color: var(--success-text); + display: flex; + gap: 0.6rem; + margin: 1rem 0; + padding: 0.7rem 0.9rem; +} + +.plan-card__linked small { + color: var(--text-muted); + display: block; +} + +.plan-card__linked button { + margin-left: auto; + padding: 0; +} + +.plan-card__link-case { + background: var(--surface-subtle); + border: 1px dashed var(--border-strong); + border-radius: 10px; + margin: 1rem 0; + padding: 0.85rem 1rem; +} + +.plan-card__link-case>p { + color: var(--text-muted); + margin: 0 0 0.6rem; +} + +.plan-case-option { + align-items: center; + background: #fff; + border: 1px solid var(--border-subtle); + border-radius: 10px; + display: flex; + gap: 0.75rem; + justify-content: space-between; + margin-bottom: 0.5rem; + padding: 0.6rem 0.8rem; +} + +.plan-case-option:last-child { + margin-bottom: 0; +} + +.plan-case-option small { + color: var(--text-muted); + display: block; +} + +.plan-card__checklist { + margin-top: 1rem; +} + +.plan-card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +.plan-card__rename { + margin-top: 1rem; +} + +.plan-card__rename summary { + color: var(--better-blue); + cursor: pointer; + font-weight: 650; +} + +.plan-card__rename-form { + display: flex; + gap: 0.6rem; + margin-top: 0.6rem; + max-width: 32rem; +} + +.plan-card__tally { + text-align: right; +} + +.plan-card__tally small { + color: var(--text-muted); + display: block; + margin-top: 0.25rem; +} \ No newline at end of file diff --git a/efile_app/efile/static/css/options.css b/efile_app/efile/static/css/options.css index 23c4ebd..d8efcd6 100644 --- a/efile_app/efile/static/css/options.css +++ b/efile_app/efile/static/css/options.css @@ -75,4 +75,30 @@ font-size: 1.8rem; font-weight: 600; margin: 0; +} + +/* Plans a filer already started, listed on the card that opens their plans. */ +.options-plan-list { + list-style: none; + margin: 0 0 0.5rem; + padding: 0; +} + +.options-plan-list li { + align-items: center; + border-top: 1px solid var(--border-subtle); + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; + padding: 0.7rem 0; +} + +.options-plan-list strong { + color: var(--text-heading); + display: block; +} + +.options-plan-list small { + color: var(--text-muted); } \ No newline at end of file diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 822b379..f1f5bef 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -682,10 +682,8 @@ margin-bottom: 0; } -.document-plan__item:has(input:checked) { - background: var(--success-surface); - border-color: var(--success-border); -} +/* An item's colour comes from the answer given about it, not from something + being checked: every item now carries a checked radio, including "Not yet". */ .document-plan__item strong { color: var(--text-heading); @@ -1142,4 +1140,316 @@ .party-row { grid-template-columns: auto 1fr auto; } +} + +/* A checklist item and the "add it to this filing" prompt that follows it. */ +.document-plan__row { + margin-bottom: 0.5rem; +} + +.document-plan__row:last-child { + margin-bottom: 0; +} + +.document-plan__row .document-plan__item { + margin-bottom: 0; +} + +.document-plan__attach, +.document-plan__attached { + background: #fff; + border: 1px solid var(--border-subtle); + border-radius: 0 0 10px 10px; + border-top: 0; + margin: -2px 0.6rem 0; + padding: 0.75rem 0.85rem; +} + +.document-plan__attach[hidden] { + display: none; +} + +.document-plan__attach-lede { + color: var(--text-muted); + font-size: 0.9rem; + margin: 0 0 0.6rem; +} + +.document-plan__attach-fields { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.document-plan__attach-fields .form-field { + display: flex; + flex-direction: column; + flex: 1 1 12rem; + gap: 0.2rem; +} + +.document-plan__attach-fields .form-field span { + color: var(--text-muted); + font-size: 0.82rem; +} + +.document-plan__attached { + align-items: center; + color: var(--success-text); + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0; +} + +.document-plan__attached small { + color: var(--text-muted); + display: block; +} + +.document-plan__attached button { + margin-left: auto; + padding: 0; +} + +.plan-alert { + align-items: flex-start; + background: var(--warning-surface); + border: 1px solid var(--warning-border); + border-radius: 10px; + display: flex; + gap: 0.8rem; + margin: 0 0 1.1rem; + padding: 0.9rem 1rem; +} + +.plan-alert i { + color: var(--danger-icon); + margin-top: 0.2rem; +} + +.plan-alert strong { + color: var(--text-heading); +} + +.plan-alert ul { + margin: 0.35rem 0; + padding-left: 1.2rem; +} + +.plan-alert p { + color: var(--text-muted); + margin: 0.35rem 0 0; +} + +.plan-alert__actions { + display: flex; + gap: 0.6rem; + margin-top: 0.6rem; +} + +.document-plan__link { + color: var(--text-muted); + display: flex; + gap: 0.5rem; + margin-top: 1.25rem; +} + +.plan-alert li small { + color: var(--text-muted); + display: block; +} + +.document-plan__attach-fields .form-field span { + text-align: left; +} + +/* Which side of the case the filer is on. Asked on the confirm-filing step for + case types that have sides, and on the checklist step when the case type was + not known until after the case lookup. */ +.filer-role__lede { + color: var(--text-muted); + margin: -0.3rem 0 0.2rem; +} + +.filer-role__options { + display: grid; + gap: 0.7rem; +} + +.filer-role__options label { + align-items: flex-start; + background: #fff; + border: 1px solid var(--border-default); + border-radius: 8px; + display: flex; + gap: 0.75rem; + padding: 0.8rem; +} + +.filer-role__options label:has(input:checked) { + background: var(--surface-accent); + border-color: var(--better-blue); +} + +.filer-role__hint { + color: var(--better-blue); + font-size: 0.8rem; + font-style: normal; + font-weight: 600; +} + +.filer-role__chosen { + align-items: center; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: -0.6rem 0 1.1rem; +} + +.filer-role__chosen strong { + color: var(--text-heading); +} + +/* Where the filer is with one document, and the narrative about the list. */ +.document-plan__item { + flex-wrap: wrap; + justify-content: space-between; +} + +.document-plan__item--have, +.document-plan__item--filed { + background: var(--success-surface); + border-color: var(--success-border); +} + +.document-plan__item--later { + background: var(--warning-surface); + border-color: var(--warning-border); +} + +.plan-item-status { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-left: auto; +} + +/* Four answers side by side, joined into one control. The native radios stay + in the page but out of sight, so arrow keys and focus rings still work. */ +.segmented { + border: 1px solid var(--border-default); + border-radius: 999px; + display: flex; + margin: 0; + overflow: hidden; + padding: 0; +} + +.segmented__option { + position: relative; +} + +.segmented__option+.segmented__option { + border-left: 1px solid var(--border-subtle); +} + +.segmented__option input { + height: 100%; + left: 0; + margin: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; +} + +.segmented__option span { + background: #fff; + color: var(--text-muted); + cursor: pointer; + display: block; + font-size: 0.78rem; + font-weight: 650; + padding: 0.4rem 0.7rem; + transition: background 0.15s ease, color 0.15s ease; + white-space: nowrap; +} + +.segmented__option:hover span { + background: var(--surface-subtle); +} + +.segmented__option input:checked+span { + background: var(--better-blue); + color: #fff; +} + +/* "Not yet" is where every item starts, so it is shown as the absence of an + answer rather than as a decision the filer made. */ +.segmented__option input[value=""]:checked+span { + background: var(--border-subtle); + color: var(--text-heading); +} + +.segmented__option input:focus-visible+span { + outline: 2px solid var(--better-blue); + outline-offset: -2px; +} + +.plan-item-status__due[hidden] { + display: none; +} + +.plan-item-status__due { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.plan-item-status__due span { + color: var(--text-muted); + font-size: 0.8rem; +} + +.plan-item-status__due em { + font-style: normal; +} + +.plan-item-status__promise { + color: var(--text-muted); + display: block; +} + +.plan-about { + background: var(--surface-accent); + border: 1px solid var(--border-accent); + border-radius: 10px; + margin: 0 0 1.1rem; + padding: 0.7rem 0.9rem; +} + +.plan-about summary { + color: var(--better-blue); + cursor: pointer; + font-weight: 650; +} + +.plan-about__body { + margin-top: 0.6rem; +} + +.plan-about__body p { + color: var(--text-body); + margin: 0 0 0.6rem; +} + +.plan-about__link { + align-items: baseline; + display: flex; + gap: 0.45rem; + margin-bottom: 0 !important; } \ No newline at end of file diff --git a/efile_app/efile/static/js/checklist-status.js b/efile_app/efile/static/js/checklist-status.js new file mode 100644 index 0000000..0d3ac2a --- /dev/null +++ b/efile_app/efile/static/js/checklist-status.js @@ -0,0 +1,25 @@ +// Answering "I have it now" is the moment to offer to add it: waiting until the +// list is saved hides the one action that puts the document in the envelope. +// "I will file it later" is the moment to ask when, and only then. +(function() { + document.querySelectorAll("input[type=radio][data-due-target]").forEach((radio) => { + const dueField = document.getElementById(radio.dataset.dueTarget); + const attachPrompt = document.getElementById(`attach-${radio.name.replace(/^status_/, "")}`); + + const row = radio.closest(".document-plan__item"); + + radio.addEventListener("change", () => { + if (!radio.checked) return; + if (row) { + // The row is tinted by the answer, so it has to follow one that + // has been given but not yet saved. + row.className = row.className.replace(/document-plan__item--\S+/, ""); + row.classList.add(`document-plan__item--${radio.value || "none"}`); + } + if (dueField) dueField.hidden = radio.value !== "later"; + if (!attachPrompt) return; + attachPrompt.hidden = radio.value !== "have"; + if (radio.value === "have") attachPrompt.querySelector("input, select, button")?.focus(); + }); + }); +})(); \ 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 e6158ff..a152f2c 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -58,6 +58,12 @@ return String(item.value ?? item.code ?? item.id ?? ""); } + function escapeHtml(value) { + const holder = document.createElement("span"); + holder.textContent = value ?? ""; + return holder.innerHTML; + } + function optionText(item) { return (item.text || item.name || optionValue(item)).replace(/\s*\(Recommended\)$/, ""); } @@ -233,10 +239,68 @@ } } + // Which side of the case the filer is on. Only some case types have sides + // -- an eviction is two different filings depending on who is making it -- + // and which ones depends on the case type chosen above, so the question + // appears and disappears with it. + const roleField = document.getElementById("filer-role-field"); + const roleOptions = document.getElementById("filer-role-options"); + let savedRole = context.filer_role || ""; + + function chosenRole() { + return roleOptions.querySelector('input[name="filer_role"]:checked')?.value || ""; + } + + function roleOptionHtml(role) { + const hint = role.suggested && !savedRole ? + ` ${gettext("probably you, from the document you uploaded")}` : + ""; + const description = role.description ? `${escapeHtml(role.description)}` : ""; + return ` + `; + } + + async function loadFilerRoles() { + // Keep an answer the filer already gave while they edit other fields. + savedRole = chosenRole() || savedRole; + const caseTypeName = fields.case_type.nameInput.value; + if (!caseTypeName) { + roleField.hidden = true; + roleOptions.innerHTML = ""; + return; + } + let roles = []; + try { + roles = await getJson(`/api/filer-roles/?${new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: fields.court.select.value, + case_category_name: fields.case_category.nameInput.value, + case_type_name: caseTypeName, + filing_type_name: fields.filing_type.nameInput.value, + })}`); + } catch (error) { + // A case type with no sides is the norm, and so is the answer to + // this call being nothing. Failing quietly leaves the filer with + // the screen they had before, rather than an error about a + // question most cases never ask. + console.warn("Could not load the sides of this case:", error); + } + roleOptions.innerHTML = roles.map(roleOptionHtml).join(""); + roleField.hidden = roles.length === 0; + } + + async function loadFilingTypesAndRoles() { + await loadFilingTypes(); + await loadFilerRoles(); + } + const ADVANCE = { court: loadCaseCategories, case_category: loadCaseTypes, - case_type: loadFilingTypes, + case_type: loadFilingTypesAndRoles, filing_type: async () => {}, }; @@ -250,15 +314,16 @@ }); fields.case_type.select.addEventListener("change", () => { fields.case_type.nameInput.value = fields.case_type.select.selectedOptions[0]?.textContent || ""; - loadFilingTypes(); + loadFilingTypesAndRoles(); }); fields.filing_type.select.addEventListener("change", () => { fields.filing_type.nameInput.value = fields.filing_type.select.selectedOptions[0]?.textContent || ""; + loadFilerRoles(); }); form.querySelectorAll('input[name="existing_case"]').forEach((radio) => { radio.addEventListener("change", () => { - if (fields.case_type.select.value) loadFilingTypes(); + if (fields.case_type.select.value) loadFilingTypesAndRoles(); }); }); @@ -294,16 +359,19 @@ form.addEventListener("submit", (event) => { const isNew = form.querySelector('input[name="existing_case"]:checked')?.value === "new"; - const missing = isNew && (!fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value); - if (missing) { - event.preventDefault(); - errorBox.textContent = "Choose a court, case category, and case type from the lists to continue."; - errorBox.hidden = false; - errorBox.scrollIntoView({ - behavior: "smooth", - block: "center" - }); - } + const missingCase = isNew && (!fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value); + const missingRole = !roleField.hidden && !chosenRole(); + if (!missingCase && !missingRole) return; + + event.preventDefault(); + errorBox.textContent = missingCase ? + "Choose a court, case category, and case type from the lists to continue." : + "Choose which side of this case you are on to continue."; + errorBox.hidden = false; + (missingCase ? errorBox : roleField).scrollIntoView({ + behavior: "smooth", + block: "center" + }); }); loadCourts(); diff --git a/efile_app/efile/static/js/filing-plans.js b/efile_app/efile/static/js/filing-plans.js new file mode 100644 index 0000000..fcc439f --- /dev/null +++ b/efile_app/efile/static/js/filing-plans.js @@ -0,0 +1,88 @@ +// Offer the filer's own court cases as things a plan can be linked to. +// +// Tyler identifies a case by a tracking ID that no one can be expected to type, +// so the only honest way to link one is to pick it from cases the filer already +// has. The list comes from their accepted filings. +(function() { + const pickers = document.querySelectorAll(".plan-case-picker"); + if (!pickers.length) return; + + const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || ""; + + function escapeHtml(value) { + const element = document.createElement("span"); + element.textContent = value ?? ""; + return element.innerHTML; + } + + async function acceptedCases() { + const since = new Date(); + since.setFullYear(since.getFullYear() - 5); + const response = await apiUtils.get("/api/filings", { + start_date: since.toISOString().split("T")[0] + }, true); + const filings = response.data || []; + + let courtNames = {}; + try { + const courts = (await apiUtils.get("/api/dropdowns/courts")).data || []; + courtNames = courts.reduce((names, court) => { + names[court.value] = court.text; + return names; + }, {}); + } catch (error) { + console.warn("Could not load court names:", error); + } + + // One case can have many filings; the filer is choosing the case. + const cases = new Map(); + for (const filing of filings) { + if (filing.filing_status !== "accepted") continue; + if (!filing.case_tracking_id || !filing.case_number) continue; + if (cases.has(filing.case_tracking_id)) continue; + cases.set(filing.case_tracking_id, { + case_tracking_id: filing.case_tracking_id, + docket_number: filing.case_number, + case_title: filing.case_title || "", + court_code: filing.court_code || "", + court_name: courtNames[filing.court_code] || "" + }); + } + return [...cases.values()]; + } + + function caseFormHtml(planId, courtCase) { + const hidden = ["case_tracking_id", "docket_number", "case_title", "court_code", "court_name"] + .map((field) => ``) + .join(""); + return ` +
+ + + + ${hidden} + + ${escapeHtml(courtCase.docket_number)} + ${escapeHtml([courtCase.case_title, courtCase.court_name].filter(Boolean).join(" · "))} + + +
`; + } + + acceptedCases().then((cases) => { + pickers.forEach((picker) => { + if (!cases.length) { + picker.innerHTML = `

${gettext( + "You have no accepted court cases yet. Once the court accepts a filing, its case will show up here." + )}

`; + return; + } + picker.innerHTML = cases.map((courtCase) => caseFormHtml(picker.dataset.planId, courtCase)).join(""); + }); + }).catch((error) => { + console.warn("Could not load your court cases:", error); + pickers.forEach((picker) => { + picker.innerHTML = `

${gettext("We could not load your court cases right now.")}

`; + }); + }); +})(); \ 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 873738f..c6b92ca 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -127,8 +127,14 @@ let savedComponent = card.dataset.filingComponent; if (!savedComponent && components.length) { - const preferredWord = card.dataset.role === "lead" ? "lead" : "attachment"; - const preferred = components.find((item) => optionText(item).toLowerCase().includes(preferredWord)); + // Each document goes to the court as its own filing, under its own + // filing type, so each one needs that filing type's *required* + // component -- the lead document. Defaulting a supporting document + // to "Attachments" leaves its filing with no lead document at all, + // which the court rejects ("Required filing component '332' not + // found") long after the filer has left this screen. + const preferred = components.find((item) => item.required === true || item.required === "true") || + components.find((item) => String(item.efspcode || "").toUpperCase() === "LEAD"); savedComponent = optionValue(preferred || components[0]); } setRadioOptions( diff --git a/efile_app/efile/templates/efile/components/checklist_item_status.html b/efile_app/efile/templates/efile/components/checklist_item_status.html new file mode 100644 index 0000000..f311705 --- /dev/null +++ b/efile_app/efile/templates/efile/components/checklist_item_status.html @@ -0,0 +1,38 @@ +{% load i18n %} +{% comment %} +One document on the plan, and where the filer is with it. Having it is only one +way to be done: it may already be at the court from an earlier filing, or be +deliberately left until later. All four answers are on show rather than folded +into a menu, so the choice is one click and the shape of the question is visible +at a glance. Expects `item`, `status_choices`, and `form_id` (the form these +controls belong to, which is not their parent element). +{% endcomment %} +
+
+ + {% blocktranslate with label=item.label %}Where you are with {{ label }}{% endblocktranslate %} + + {% for choice in status_choices %} + + {% endfor %} +
+ +
diff --git a/efile_app/efile/templates/efile/components/plan_about.html b/efile_app/efile/templates/efile/components/plan_about.html new file mode 100644 index 0000000..5f72cdd --- /dev/null +++ b/efile_app/efile/templates/efile/components/plan_about.html @@ -0,0 +1,26 @@ +{% load i18n %} +{% comment %} +What this list is for, and what it cannot know. Folded away by default: a filer +who wants to get on with filing should not have to read it, and a filer who +wonders "is this really everything?" deserves a straight answer. Expects +`guidance` (a plan's snapshotted about block) and `panel_id`. +{% endcomment %} +
+ {% translate "About this list" %} +
+ {% if guidance.summary %}

{{ guidance.summary }}

{% endif %} +

+ {% translate "This list is a guide, not legal advice. We built it from what cases like yours usually need, so your case may need documents that are not here, and may not need some that are. Only the court can tell you for certain, and the clerk cannot give you legal advice." %} +

+ {% if guidance.learn_more_url %} + + {% endif %} +
+
diff --git a/efile_app/efile/templates/efile/components/profile_header.html b/efile_app/efile/templates/efile/components/profile_header.html index f8e4c51..1803fda 100644 --- a/efile_app/efile/templates/efile/components/profile_header.html +++ b/efile_app/efile/templates/efile/components/profile_header.html @@ -31,6 +31,15 @@

+ + + {% translate "My filing plans" %} + + + + {% translate "My past filings" %} + + {% else %}

{% translate "You are not signed in" %}

diff --git a/efile_app/efile/templates/efile/document_checklist.html b/efile_app/efile/templates/efile/document_checklist.html index 421ba32..9b8c541 100644 --- a/efile_app/efile/templates/efile/document_checklist.html +++ b/efile_app/efile/templates/efile/document_checklist.html @@ -26,28 +26,128 @@

{% translate "Do you have all your documents?" %}

{% endif %}
+ {% if choosing_filer_role %} +
+

{% translate "Which side of this case are you on?" %}

+

+ {% translate "Each side files different documents, so we need to know yours before we can list them." %} +

+
+ {% csrf_token %} + + +
+ {% translate "Your side of the case" %} + {% for role in filer_roles %} + + {% endfor %} +
+ +
+
+ {% endif %} {% if checklist_groups %}

{% translate "Your document plan" %}

- {% translate "Check off what you have. We save this list, so you can come back to it." %} + {% translate "Say where you are with each document, and add the ones you have. We save this list, so you can come back to it." %}

+ {% include "efile/components/plan_about.html" with guidance=guidance panel_id="checklist-about" %} + {% if filer_role_label %} +

+ + {% blocktranslate with side=filer_role_label %}This list is for {{ side }}.{% endblocktranslate %} + {% translate "Not you?" %} +

+ {% endif %} + {% if ready_to_add %} +
+ +
+ {% blocktranslate count counter=ready_to_add|length %}You have 1 document that is not in this filing yet{% plural %}You have {{ counter }} documents that are not in this filing yet{% endblocktranslate %} +
    + {% for item in ready_to_add %} +
  • {{ item.label }}
  • + {% endfor %} +
+

+ {% translate "Add them below if they are ready. If you send them later, the clerk may wait for them before acting on your case." %} +

+
+
+ {% endif %} {% for group in checklist_groups %}
{{ group.label }} {% for item in group.items %} - +
+
+ + {{ item.label }} + {% if item.description %}{{ item.description }}{% endif %} + + {% if not item.attached %} + {% include "efile/components/checklist_item_status.html" with item=item status_choices=status_choices form_id="checklist-confirm-form" %} + {% endif %} +
+ {% if item.attached %} +

+ + {% blocktranslate with filename=item.attached.name|default:item.attached.original_filename %}In this filing as {{ filename }}{% endblocktranslate %} + +

+ {% else %} +
+

+ {% translate "Add it to this filing now, so the court gets it with everything else." %} +

+
+ + {% if documents %} + + {% endif %} + +
+
+ {% endif %} +
{% endfor %}
{% endfor %} @@ -58,6 +158,33 @@

{% translate "Your document plan" %}

value="save_progress" formnovalidate>{% translate "Save my list" %}
+ {% comment %} + One form per checklist item, kept out of the list markup: a form + cannot be nested inside the confirm form, and the controls above + reach these by id with the form attribute. + {% endcomment %} + {% for group in checklist_groups %} + {% for item in group.items %} + {% if item.attached %} +
+ {% csrf_token %} + + + +
+ {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} + {% endfor %} + {% endfor %} {% endif %}

{% translate "Files you have added" %}

@@ -111,6 +238,7 @@

{% translate "Files you have added" %}

{% csrf_token %} +