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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,497 changes: 1,497 additions & 0 deletions efile_app/efile/data/massachusetts_lower_courts.json

Large diffs are not rendered by default.

84 changes: 60 additions & 24 deletions efile_app/efile/management/commands/seed_accessibility_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,35 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("--output", required=True, help="Path for Playwright storage state JSON.")
parser.add_argument("--origin", default="http://127.0.0.1:8000", help="Origin served to Playwright.")
parser.add_argument(
"--appeal",
action="store_true",
help="Seed a new-appeal draft for appellate browser checks.",
)
parser.add_argument(
"--jurisdiction",
choices=("illinois", "massachusetts"),
default="illinois",
help="Jurisdiction for the browser fixture (default: illinois).",
)

def handle(self, *args, **options):
user_model = settings.AUTH_USER_MODEL
from django.apps import apps

user_class = apps.get_model(user_model)
jurisdiction = options["jurisdiction"]
is_illinois = jurisdiction == "illinois"
username = "accessibility-checker" if is_illinois else f"accessibility-checker-{jurisdiction}"
email = (
"accessibility-checker@example.com" if is_illinois else f"accessibility-checker-{jurisdiction}@example.com"
)
user, _ = user_class.objects.update_or_create(
username="accessibility-checker",
username=username,
defaults={
"email": "accessibility-checker@example.com",
"tyler_jurisdiction": "illinois",
"tyler_username": "accessibility-checker@example.com",
"email": email,
"tyler_jurisdiction": jurisdiction,
"tyler_username": email,
"first_name": "Avery",
"last_name": "Checker",
},
Expand All @@ -41,22 +58,39 @@ def handle(self, *args, **options):

FilingDraft.objects.filter(user=user).delete()
FilingPlan.objects.filter(user=user).delete()
plan = FilingPlan.objects.create(user=user, jurisdiction="illinois", title="Accessibility test filing")
plan = FilingPlan.objects.create(user=user, jurisdiction=jurisdiction, title="Accessibility test filing")
appeal = options["appeal"]
if appeal and jurisdiction not in {"illinois", "massachusetts"}:
raise ValueError("The appeal browser fixture supports Illinois and Massachusetts.")
court_code = ("TAC1" if is_illinois else "appeals:acp") if appeal else "cook:cvd1"
court_name = (
("Appellate Court - 1st District" if is_illinois else "Massachusetts Appeals Court (Panel)")
if appeal
else "Cook County"
)
case_category_code = ("21083" if is_illinois else "5796") if appeal else "6198"
case_category_name = (
("Appeal" if is_illinois else "Appeals Court Panel Cases - Civil") if appeal else "Small Claims"
)
case_type_code = ("37653" if is_illinois else "7660") if appeal else "183541"
case_type_name = ("Notice of Appeal - Civil" if is_illinois else "Contract dispute") if appeal else "Contract"
filing_type_code = "30341" if appeal else "143132"
filing_type_name = "Notice of Appeal" if appeal else "Complaint"
draft = FilingDraft.objects.create(
user=user,
plan=plan,
jurisdiction="illinois",
jurisdiction=jurisdiction,
workflow_version=2,
existing_case=ExistingCase.NEW,
current_step=WorkflowStepKey.REVIEW,
court_code="cook:cvd1",
court_name="Cook County",
case_category_code="6198",
case_category_name="Small Claims",
case_type_code="183541",
case_type_name="Contract",
filing_type_code="143132",
filing_type_name="Complaint",
current_step=WorkflowStepKey.PARTIES if appeal else WorkflowStepKey.REVIEW,
court_code=court_code,
court_name=court_name,
case_category_code=case_category_code,
case_category_name=case_category_name,
case_type_code=case_type_code,
case_type_name=case_type_name,
filing_type_code=filing_type_code,
filing_type_name=filing_type_name,
document_checklist_acknowledged=True,
# Something for the confirm-filing screen to show, so its
# "I checked this" acknowledgement is rendered and audited too.
Expand All @@ -67,13 +101,13 @@ def handle(self, *args, **options):
FilingDocument.objects.create(
draft=draft,
role=FilingDocument.Role.LEAD,
name="Accessibility complaint.pdf",
original_filename="Accessibility complaint.pdf",
name=f"{filing_type_name}.pdf" if appeal else "Accessibility complaint.pdf",
original_filename=f"{filing_type_name}.pdf" if appeal else "Accessibility complaint.pdf",
# This fixture starts downstream of preparation and confirmation.
preparation="unchanged",
preparation_reviewed_at=timezone.now(),
filing_type_code="143132",
filing_type_name="Complaint",
filing_type_code=filing_type_code,
filing_type_name=filing_type_name,
document_type_code="public",
document_type_name="Public",
)
Expand All @@ -84,9 +118,9 @@ def handle(self, *args, **options):
first_name="Avery",
last_name="Checker",
address_line_1="100 Main Street",
city="Chicago",
state="IL",
zip_code="60601",
city="Chicago" if is_illinois else "Boston",
state="IL" if is_illinois else "MA",
zip_code="60601" if is_illinois else "02108",
email=user.email,
party_type="plaintiff",
party_type_name="Plaintiff",
Expand All @@ -98,6 +132,8 @@ def handle(self, *args, **options):
sort_order=1,
first_name="Jordan",
last_name="Example",
party_type="defendant" if appeal else "",
party_type_name="Defendant" if appeal else "",
)

# A quote priced on this draft as it now stands, so Review shows it as
Expand All @@ -111,8 +147,8 @@ def handle(self, *args, **options):
client.force_login(user)
session = client.session
session[CURRENT_DRAFT_SESSION_KEY] = draft.pk
session["jurisdiction"] = "illinois"
session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "accessibility-test-token"}
session["jurisdiction"] = jurisdiction
session["auth_tokens"] = {f"TYLER-TOKEN-{jurisdiction.upper()}": "accessibility-test-token"}
session.save()

output = Path(options["output"])
Expand Down
130 changes: 130 additions & 0 deletions efile_app/efile/services/appeals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Originating case details for new appellate cases.

Massachusetts uses unpublished, environment-specific lower-court IDs maintained
by MACourts. Other jurisdictions use the EFSP's court location codes.
"""

import hashlib
import json
import logging
from pathlib import Path

import requests
from django.conf import settings
from django.core.cache import cache

from efile.services.court_selection import is_non_filing_court

logger = logging.getLogger(__name__)
LOWER_COURT_FIELDS = (
"lower_court_code",
"lower_court_name",
"lower_court_prod_code",
"lower_court_docket_number",
"lower_court_title",
"lower_court_judge",
)


def code_list(jurisdiction, path, lookups=None):
"""Return an EFSP code list, or None when it could not be loaded.

Pass the payload's ``lookups`` to fetch a cache miss within that request's
time budget. Its failures are not remembered: a spent budget belongs to one
request, not to the EFSP.
"""
url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/{path}"
key = "appeal-codes:" + hashlib.sha256(url.encode()).hexdigest()
result = cache.get(key)
if result is not None:
return result if isinstance(result, list) else None
if lookups is not None:
result = lookups.get(url)
if not isinstance(result, list):
return None
cache.set(key, result, 300)
return result
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
result = response.json()
if isinstance(result, list):
cache.set(key, result, 300)
return result
except (requests.RequestException, ValueError):
logger.warning("Could not load appellate code list %s", url)
# A single page consults this list for routing, validation, and its summary.
# Briefly remember an outage so those checks do not each wait ten seconds.
cache.set(key, False, 30)
return None


def is_new_appeal(draft):
if draft.existing_case != "new" or draft.previous_case_id or not draft.court_code or not draft.case_category_code:
return False
categories = code_list(draft.jurisdiction, f"courts/{draft.court_code}/categories")
for category in categories or []:
if isinstance(category, dict) and str(category.get("code")) == str(draft.case_category_code):
return category.get("ecfcasetype") == "AppellateCase"
# Keep the questions reachable during an outage, including for saved drafts.
description = f"{draft.court_name} {draft.case_category_name}".lower()
return any(word in description for word in ("appeal", "appellate", "supreme", "single justice"))


def lower_court_options(jurisdiction, lookups=None):
"""Return the lower-court choices, or None when the EFSP's list is unavailable."""
if jurisdiction == "massachusetts":
source = Path(__file__).resolve().parent.parent / "data" / "massachusetts_lower_courts.json"
rows = json.loads(source.read_text())["courts"]
return [
{
"value": row["tyler_lower_court_code"],
"label": row["name"],
"prod_code": row["tyler_prod_lower_court_code"],
}
for row in rows
]
rows = code_list(jurisdiction, "courts/?fileable_only=false&with_names=true", lookups=lookups)
if rows is None:
return None
return sorted(
[
{"value": str(row["code"]), "label": row["name"], "prod_code": str(row["code"])}
for row in rows
if isinstance(row, dict)
and row.get("code")
and row.get("name")
and not is_non_filing_court(row["name"])
and not any(word in row["name"].lower() for word in ("appellate", "appeals court", "supreme"))
],
key=lambda row: row["label"],
)


def appeal_questions(draft):
if not is_new_appeal(draft):
return []
return [
{
"name": "lower_court_code",
"label": "Lower court",
"type": "select",
"required": True,
"options": lower_court_options(draft.jurisdiction) or [],
},
{"name": "lower_court_docket_number", "label": "Lower court case number", "type": "text", "required": True},
{"name": "lower_court_title", "label": "Lower court case caption", "type": "text", "required": True},
{"name": "lower_court_judge", "label": "Lower court judge", "type": "text", "required": False},
]


def appeal_answers_complete(draft):
questions = appeal_questions(draft)
answers = draft.supplemental_fields or {}
for question in questions:
value = str(answers.get(question["name"]) or "").strip()
if question["required"] and not value:
return False
if question["type"] == "select" and value not in {o["value"] for o in question["options"]}:
return False
return True
5 changes: 5 additions & 0 deletions efile_app/efile/services/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
for key, value in (draft.supplemental_fields or {}).items():
data[key] = value

from efile.services.appeals import LOWER_COURT_FIELDS, is_new_appeal

if any(key in data for key in LOWER_COURT_FIELDS) and not is_new_appeal(draft):
for key in LOWER_COURT_FIELDS:
data.pop(key, None)
return data


Expand Down
57 changes: 55 additions & 2 deletions efile_app/efile/services/efsp_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id):
"""
lookups = _EfspLookups()
_clean_case_identifiers(efile_data)
validate_lower_court(efile_data, jurisdiction_id, court_id, lookups=lookups)
_drop_empty_cross_references(efile_data)
substitute_test_document_urls(efile_data)
validate_document_selections(efile_data)
Expand All @@ -136,8 +137,9 @@ def prepare_efile_payload(efile_data, jurisdiction_id, court_id):


def _clean_case_identifiers(efile_data):
"""Ensure docket_number is omitted for new cases where there is no previous_case_id."""
if not efile_data.get("previous_case_id") or efile_data.get("user_started_case") is True:
"""New cases have neither a previous case ID nor a court-assigned docket number."""
if not str(efile_data.get("previous_case_id") or "").strip() or efile_data.get("user_started_case") is True:
efile_data.pop("previous_case_id", None)
efile_data.pop("docket_number", None)


Expand Down Expand Up @@ -430,3 +432,54 @@ def _lookup_optional_services_meta(jurisdiction_id, court_id, filing_type, looku
if not isinstance(services, list):
return None
return {service["code"]: service for service in parse_optional_services(services)}


def validate_lower_court(efile_data, jurisdiction_id, court_id, lookups=None):
"""Use the same court options for fee quotes and submission as the form.

When the EFSP's court list is unavailable, the filer's choice is passed
through rather than rejected; the EFSP stays the authority.
"""
from efile.services.appeals import code_list, lower_court_options

lookups = lookups or _EfspLookups()

if efile_data.get("previous_case_id"):
efile_data.pop("lower_court_case", None)
efile_data.pop("trial_court", None)
return
category_code = efile_data.get("efile_case_category")
categories = code_list(jurisdiction_id, f"courts/{court_id}/categories", lookups=lookups) if category_code else []
category = next(
(row for row in categories or [] if isinstance(row, dict) and str(row.get("code")) == str(category_code)), None
)
if category is not None and category.get("ecfcasetype") != "AppellateCase":
efile_data.pop("lower_court_case", None)
efile_data.pop("trial_court", None)
return
lower = efile_data.get("lower_court_case")
if category is None and not lower:
return
if not isinstance(lower, dict) or not all(str(lower.get(key) or "").strip() for key in ("title", "docket_number")):
raise PayloadValidationError(
"Enter the lower court case number and caption before checking fees or submitting."
)
court = efile_data.get("trial_court") or {}
code = court.get("tyler_lower_court_code") if isinstance(court, dict) else None
options = lower_court_options(jurisdiction_id, lookups=lookups)
if options is None and code:
logger.warning("Lower courts for %s are unavailable; passing %s through unchecked", jurisdiction_id, code)
# Only Massachusetts has separate production codes, and its list is local.
selected = {"value": code, "label": court.get("name") or code, "prod_code": code}
else:
selected = next((option for option in options or [] if option["value"] == code), None)
if selected is None:
raise PayloadValidationError("Choose a valid lower court from the list before checking fees or submitting.")
efile_data["trial_court"] = {
"name": selected["label"],
"tyler_lower_court_code": selected["value"],
"tyler_prod_lower_court_code": selected["prod_code"],
}
efile_data["lower_court_case"] = {
key: str(lower.get(key) or "").strip() for key in ("title", "docket_number", "judge")
}
3 changes: 3 additions & 0 deletions efile_app/efile/services/fee_quotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ def fee_inputs(draft: FilingDraft, *, payment_account_id: str | None = None) ->
"existing_case": draft.existing_case,
"previous_case_id": draft.previous_case_id,
"amount_in_controversy": draft.amount_in_controversy,
"lower_court": {
key: value for key, value in (draft.supplemental_fields or {}).items() if key.startswith("lower_court_")
},
"optional_services": json.dumps(draft.optional_services or [], sort_keys=True, default=str),
"payment_account": draft.selected_payment_account_id if payment_account_id is None else payment_account_id,
"documents": documents,
Expand Down
5 changes: 4 additions & 1 deletion efile_app/efile/services/people.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from efile.models import FilingDocument, FilingDraft, FilingParty
from efile.party_sides import PARTY_SIDE_KEYWORDS, PartySide, side_for_party_type_name
from efile.services.appeals import appeal_questions
from efile.services.document_checklists import party_type_keywords_for_role
from efile.services.extracted_parties import extracted_party_suggestions, party_display_name
from efile.services.party_requirements import address_is_blank, address_is_complete, party_address_requirement
Expand Down Expand Up @@ -548,7 +549,7 @@ def get_case_questions(draft: FilingDraft) -> list[dict[str, Any]]:
case_type,
court=draft.court_code,
)
questions: list[dict[str, Any]] = []
questions: list[dict[str, Any]] = appeal_questions(draft)
for section in (config or {}).get("sections", {}).values():
for group in section.get("fields", []):
for field in group.get("fields", []):
Expand All @@ -571,6 +572,8 @@ def get_case_questions(draft: FilingDraft) -> list[dict[str, Any]]:


def parse_question_answer(question: dict[str, Any], value: Any) -> Any:
if question["type"] == "select" and value not in {option["value"] for option in question["options"]}:
raise ValueError("Choose a court from the list.")
if question["type"] == "radio":
if str(value).lower() == "true":
return True
Expand Down
Loading
Loading