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/filing_views.py b/efile_app/efile/api/filing_views.py
index 4fce663..2bffe3d 100644
--- a/efile_app/efile/api/filing_views.py
+++ b/efile_app/efile/api/filing_views.py
@@ -45,6 +45,55 @@ def get_tyler_token(request, jurisdiction=None):
return tyler_token
+def list_filing_data(request, jurisdiction, *, start_date=None):
+ """Return the current Tyler account's filings in its normalized API shape.
+
+ Both the filing-history endpoint and the plan case-link action need this
+ account-scoped list. Keeping the proxy call here means the latter cannot
+ accidentally validate a browser-supplied case ID instead.
+ """
+
+ api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/0/filings"
+ headers = get_headers()
+ tyler_token = get_tyler_token(request, jurisdiction)
+ if tyler_token:
+ headers[f"tyler-token-{jurisdiction}"] = tyler_token
+ else:
+ logger.info("No Tyler token found for state '%s' in filing-history request", jurisdiction)
+
+ response = requests.get(
+ api_url,
+ params={"start_date": start_date or None, "before_date": None},
+ headers=headers,
+ timeout=30,
+ )
+ logger.debug(
+ "Get filings response: status=%s content_type=%s",
+ response.status_code,
+ response.headers.get("Content-Type"),
+ )
+ response.raise_for_status()
+ return [FilingAPIViews.convert_filing_data(filing) for filing in response.json()]
+
+
+def accepted_case_for_user(request, jurisdiction, case_tracking_id, *, start_date=None):
+ """Return an accepted case the current account has filed into, if any."""
+
+ wanted = str(case_tracking_id or "")
+ if not wanted:
+ return None
+ return next(
+ (
+ filing
+ for filing in list_filing_data(request, jurisdiction, start_date=start_date)
+ if str(filing.get("filing_status", "")).lower() == "accepted"
+ and str(filing.get("case_tracking_id", "")) == wanted
+ and filing.get("case_number")
+ ),
+ None,
+ )
+
+
class FilingAPIViews(APIResponseMixin):
"""API views for filing operations"""
@@ -59,43 +108,7 @@ def get_filings(request):
if not jurisdiction:
return JsonResponse({"success": False, "error": "Jurisdiction parameter is required"}, status=400)
- court = "0" # hardcoded to get filings from all courts
- before_date = None # defaults to now
-
- api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/{court}/filings"
-
- # Add query parameter for docket number
- params = {
- "start_date": start_date if start_date else None,
- "before_date": before_date if before_date else None,
- }
-
- logger.info(f"Looking up filings in all courts at {api_url}")
-
- # Get authentication credentials dynamically
- tyler_token = get_tyler_token(request, jurisdiction)
-
- headers = get_headers()
- # Add Tyler token if available
- if tyler_token:
- headers[f"tyler-token-{jurisdiction}"] = tyler_token
- else:
- # Log that no token was found for debugging
- logger.info(
- "No Tyler token found for state '%s' in Suffolk case lookup request",
- jurisdiction,
- )
-
- # Make the API request - using GET with query parameters
- response = requests.get(api_url, params=params, headers=headers, timeout=30)
- logger.debug(
- "Get filings response: status=%s content_type=%s",
- response.status_code,
- response.headers.get("Content-Type"),
- )
- response.raise_for_status()
- api_data = [FilingAPIViews.convert_filing_data(filing) for filing in response.json()]
- return FilingAPIViews.success_response(api_data)
+ return FilingAPIViews.success_response(list_filing_data(request, jurisdiction, start_date=start_date))
except requests.RequestException as e:
logger.exception("Network error calling Suffolk API")
return FilingAPIViews.error_response(f"Network error: {str(e)}", status_code=500)
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/0012_filing_plans.py b/efile_app/efile/migrations/0012_filing_plans.py
new file mode 100644
index 0000000..b035651
--- /dev/null
+++ b/efile_app/efile/migrations/0012_filing_plans.py
@@ -0,0 +1,82 @@
+# Generated by Django 5.2.5
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("efile", "0011_merge_jurisdiction_accounts_and_workflow"),
+ ]
+
+ 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)),
+ ("filer_role", models.CharField(blank=True, max_length=60)),
+ ("case_tracking_id", models.CharField(blank=True, max_length=255)),
+ ("docket_number", models.CharField(blank=True, max_length=255)),
+ ("case_title", models.CharField(blank=True, max_length=500)),
+ ("checklist", models.JSONField(blank=True, default=dict)),
+ ("guidance", 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.AddField(
+ model_name="filingdraft",
+ name="filer_role",
+ field=models.CharField(blank=True, max_length=60),
+ ),
+ migrations.AddField(
+ model_name="filingdocument",
+ name="checklist_item_id",
+ field=models.CharField(blank=True, max_length=100),
+ ),
+ 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 54309cc..f849131 100644
--- a/efile_app/efile/models.py
+++ b/efile_app/efile/models.py
@@ -47,6 +47,78 @@ def account_email(self):
return self.tyler_username or self.email or self.username
+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)
+
+ # 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), "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)
+
+ 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}"
+
+ @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."""
@@ -62,6 +134,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(
@@ -108,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
@@ -178,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
new file mode 100644
index 0000000..c59889e
--- /dev/null
+++ b/efile_app/efile/services/document_checklists.py
@@ -0,0 +1,383 @@
+"""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.
+
+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
+
+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",
+}
+
+
+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.
+_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 _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."""
+
+ if not isinstance(condition, dict):
+ return True
+ wanted = _as_list(condition.get("lead_filing_type_names"))
+ if not wanted:
+ return True
+ 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:
+ 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,
+ 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, 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 configured.get("include") is False:
+ continue
+ 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()),
+ "requirement": _requirement(raw, item_id),
+ }
+ if raw.get("description"):
+ 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.
+ items.sort(key=lambda pair: REQUIREMENT_ORDER.index(pair[1]["requirement"]))
+ return dict(items)
+
+
+def _resolve_entry(
+ jurisdiction: str,
+ 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.
+ """
+
+ if not jurisdiction:
+ return None
+
+ 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 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
+
+
+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
new file mode 100644
index 0000000..dfb8e38
--- /dev/null
+++ b/efile_app/efile/services/filing_plans.py
@@ -0,0 +1,761 @@
+"""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 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 (
+ REQUIREMENT_LABELS,
+ REQUIREMENT_ORDER,
+ normalize_name,
+ resolve_document_checklist,
+ resolve_filer_roles,
+ resolve_plan_guidance,
+)
+from efile.services.drafts import create_draft
+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()
+ 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 = 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
+
+
+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),
+ 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 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, str]:
+ return (
+ draft.court_code,
+ draft.case_category_name,
+ draft.case_type_name,
+ draft.filer_role,
+ 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.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
+
+
+@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
+ 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)
+ 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)
+ 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,
+ 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_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 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.
+ """
+
+ held = {str(item_id) for item_id in keep_have}
+ checklist = dict(plan.checklist or {})
+ for item_id, item in checklist.items():
+ 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 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 mark_attached_items_filed(draft: FilingDraft) -> None:
+ """Record that this envelope's checklist documents reached the court.
+
+ An attached document is only "I have it" while its envelope is still in
+ progress. Once the filing succeeds, the plan must say "I already filed
+ this" so a later envelope does not ask for the same document again.
+ """
+
+ plan = draft.plan
+ if plan is None:
+ return
+
+ filed_item_ids = set(
+ FilingDocument.objects.filter(draft=draft)
+ .exclude(checklist_item_id="")
+ .values_list("checklist_item_id", flat=True)
+ )
+ if not filed_item_ids:
+ return
+
+ checklist = dict(plan.checklist or {})
+ changed = False
+ for item_id in filed_item_ids:
+ item = checklist.get(item_id)
+ if not isinstance(item, dict) or item_status(item) == STATUS_FILED:
+ continue
+ updated = {**item, "status": STATUS_FILED}
+ updated.pop("complete", None)
+ updated.pop("due_date", None)
+ checklist[item_id] = updated
+ changed = True
+ if changed:
+ 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 []
+
+ attached = attached_documents(draft)
+ items = []
+ for item_id, item in (plan.checklist or {}).items():
+ if not isinstance(item, dict) or item.get("requirement") not in REQUIREMENT_ORDER:
+ continue
+ # 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", ""),
+ "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}
+ for level, items in groups.items()
+ if items
+ ]
+
+
+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)}
+ 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
+# 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 (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 []
+
+
+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
+ 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 b0503bd..fa81447 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
@@ -36,22 +37,31 @@ efile/static/config/
├── README.md # This documentation
├── base-case-types.yaml # Base configuration (all jurisdictions)
└── states/
- ├── illinois.yaml # Illinois-specific overrides
- └── massachusetts.yaml # Massachusetts-specific overrides
+ ├── illinois.yaml # Everything specific to Illinois
+ ├── massachusetts.yaml # Everything specific to Massachusetts
+ └── vermont.yaml # Everything specific to Vermont
```
+A state file is named for its jurisdiction and nothing else — `illinois.yaml`,
+not `illinois-case-types.yaml` — because it holds everything that is specific to
+that state, and that list grows: case types, document checklists, court
+overrides, and jurisdiction display settings such as the navigation title and
+logo all live in the one file.
+
### Configuration file hierarchy
1. **Base Configuration** (`base-case-types.yaml`)
- Defines common case types and field structures
- Provides default field types and validation rules
- Acts as a template for state-specific extensions
+ - Carries **no** document checklists — see below
2. **State Configuration** (`states/{jurisdiction}.yaml`)
- Inherits from base configuration
- Adds state-specific case types
- Overrides field requirements, labels, and validation
- Defines court-specific customizations
+ - Holds the document checklists, which are always state-specific
3. **Runtime Merging**
- Base + State configurations are merged at runtime
@@ -299,6 +309,257 @@ 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.
+
+### Checklists belong to a state
+
+Every checklist key — `matches`, `documents`, `about`, `filer_roles` — is
+configured in `states/{jurisdiction}.yaml`, never in `base-case-types.yaml`.
+A name change needs a publication notice in Illinois and does not in most other
+states, and the courts of two states rarely call the same document, case type,
+or filing type by the same name. There is no useful national default to inherit,
+so a state that has not been configured yet simply shows no checklist rather
+than another state's list. `base-case-types.yaml` still supplies the shared
+*form* structure that a state case type `extends`.
+
+### 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.
+
+### 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
+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 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
### 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..fad7505 100644
--- a/efile_app/efile/static/config/base-case-types.yaml
+++ b/efile_app/efile/static/config/base-case-types.yaml
@@ -81,7 +81,14 @@ defaults:
column_width: "col-12"
-# Base case type templates that states can inherit from
+# Base case type templates that states can inherit from.
+#
+# Form structure only. Document checklists (`matches`, `documents`, `about`,
+# `filer_roles`) are deliberately not here: which forms a case needs, and what
+# the court calls them, is a question of state law and local practice, and the
+# answer is rarely the same in two states. Those live in
+# `states/.yaml` -- see the "Document checklists" section of
+# README.md.
base_case_types:
name_change:
keywords: ["name change", "name petition", "change of name"]
diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml
index 4746eca..7d16f0a 100644
--- a/efile_app/efile/static/config/states/illinois.yaml
+++ b/efile_app/efile/static/config/states/illinois.yaml
@@ -24,8 +24,110 @@ 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". Names are per state: the base file carries no list to
+ # inherit, because another state's courts call this something else.
+ matches:
+ names:
+ - "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
+ fee_waiver:
+ label: "Request to waive court fees"
+ requirement: sometimes
+ description: "File this if you cannot afford the filing fee."
+ filing_type_names:
+ - "Fee Waiver Petition Filed" # Cook County
+ - "Application for Waiver of Court Fees"
+ - "Fee Waiver"
+ - "Waiver" # 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.
+ 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 +141,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 +230,238 @@ 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. 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"
+ 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"
+ - "Appearance (No Fee)"
+ - "Appearance Filed - Fee"
+ - "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."
+
+ # 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
+ 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
+ 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.
+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 +473,32 @@ 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
+ # 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:
name_change:
# Hide both sections for Bond Court
diff --git a/efile_app/efile/static/config/states/massachusetts.yaml b/efile_app/efile/static/config/states/massachusetts.yaml
index 82dad89..4881a10 100644
--- a/efile_app/efile/static/config/states/massachusetts.yaml
+++ b/efile_app/efile/static/config/states/massachusetts.yaml
@@ -38,7 +38,12 @@ case_types:
name_change:
# Inherit from base but add Massachusetts-specific requirements
extends: "base_case_types.name_change"
-
+
+ # Form structure only so far. Document checklists are state-specific -- what
+ # Massachusetts requires, and what its courts call each form, is not what
+ # Illinois requires -- so add `matches` and `documents` here to turn one on.
+ # See the "Document checklists" section of ../README.md.
+
# Override with Massachusetts terminology
sections:
parties:
diff --git a/efile_app/efile/static/config/states/vermont.yaml b/efile_app/efile/static/config/states/vermont.yaml
index b586250..86cd1c0 100644
--- a/efile_app/efile/static/config/states/vermont.yaml
+++ b/efile_app/efile/static/config/states/vermont.yaml
@@ -18,4 +18,8 @@ inherits_from: "base-case-types.yaml"
case_types:
name_change:
- extends: "base_case_types.name_change"
\ No newline at end of file
+ extends: "base_case_types.name_change"
+ # Form structure only so far. Document checklists are state-specific -- what
+ # Vermont requires, and what its courts call each form, is not what Illinois
+ # requires -- so add `matches` and `documents` here to turn one on. See the
+ # "Document checklists" section of ../README.md.
\ No newline at end of file
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 c7b82c3..f1f5bef 100644
--- a/efile_app/efile/static/css/reorganized-flow.css
+++ b/efile_app/efile/static/css/reorganized-flow.css
@@ -604,6 +604,104 @@
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;
+}
+
+/* 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);
+ 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;
@@ -1042,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..8ed33ed
--- /dev/null
+++ b/efile_app/efile/static/js/filing-plans.js
@@ -0,0 +1,86 @@
+// 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) {
+ return `
+ `;
+ }
+
+ 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."
+ )}
${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 %}
+
+
+
+
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." %}
+
- {% 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 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." %}
+
+
+
+ {% endif %}
+ {% if checklist_groups %}
+
+
{% translate "Your document plan" %}
+
+ {% 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 %}
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+ {% else %}
+
+ {% endif %}
+ {% endfor %}
+ {% endfor %}
+ {% endif %}
+
{% translate "Files you have added" %}
{% for document in documents %}
@@ -71,6 +238,7 @@
{% translate "Leave blank if the court has not assigned one." %}
+ {% comment %}
+ Only case types that mean different jobs for different parties --
+ an eviction, say -- offer sides. The list depends on the case type
+ being chosen just above, so it is filled in by extraction-review.js
+ and stays hidden for every other case.
+ {% endcomment %}
+