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
17 changes: 14 additions & 3 deletions efile_app/efile/api/filing_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

from ..services.current_drafts import get_current_draft
from ..services.efsp_errors import describe_efsp_error
from ..services.efsp_errors import describe_efsp_error, efsp_error_problems, error_actions
from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload
from ..services.fee_quotes import fee_inputs_token, fee_quote_summary, quote_from_efsp_response, record_fee_quote
from ..utils.case_data_utils import get_case_data
Expand Down Expand Up @@ -161,14 +161,23 @@ def payment_fees(request):
case_data = get_case_data(request, jurisdiction_id)
court_id = case_data.get("court", "")

draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)

# Must match what submit_final_filing sends, or fees are quoted
# against a payload that differs from the one actually filed.
try:
prepare_efile_payload(efile_data, jurisdiction_id, court_id)
except PayloadValidationError as error:
# Known-bad payload: answer with the specific reason rather than
# letting the EFSP reply with a code-list error no filer can act on.
return JsonResponse({"success": False, "error": str(error)}, status=400)
return JsonResponse(
{
"success": False,
"error": str(error),
"error_actions": error_actions(draft, efile_data, error.problems),
},
status=400,
)

url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/filingreview/courts/{court_id}/filing/fees"

Expand All @@ -188,7 +197,6 @@ def payment_fees(request):
# The draft this request prices, as the page that built it saw it.
# Checked again when the EFSP answers: the request can take a
# minute, and the filing can be edited in another tab meanwhile.
draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
inputs_token = str(data.get("fee_inputs_token") or "")
priced_current_draft = draft is not None and bool(inputs_token) and fee_inputs_token(draft) == inputs_token

Expand Down Expand Up @@ -234,6 +242,9 @@ def payment_fees(request):
{
"success": False,
"error": f"Could not get filing fees: {error_message}",
"error_actions": error_actions(
draft, efile_data, efsp_error_problems(response), message=error_message
),
"api_status_code": response.status_code,
"api_response": response.text[:500] if response.text else "No response body",
},
Expand Down
63 changes: 10 additions & 53 deletions efile_app/efile/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.contrib.auth import get_user_model
from django.db.models import Q

from efile.services.efsp_validation import state_choices
from efile.services.postal_codes import USPS_STATES
from efile.utils.account_ids import jurisdiction_account_username

User = get_user_model()
Expand All @@ -28,6 +30,13 @@ class EFileRegistrationForm(forms.Form):
def __init__(self, *args, jurisdiction=None, **kwargs):
super().__init__(*args, **kwargs)
self.jurisdiction = jurisdiction
choices, source = state_choices(jurisdiction, "0")
self.fields["state"].choices = [("", "Select a state"), *choices]
if source == "efsp":
self.fields["state"].help_text = (
"Only locations accepted by the filing service are listed. If your address is not listed, "
"contact the court for filing instructions. Do not choose a different state."
)

# Legal Name
first_name = forms.CharField(
Expand All @@ -54,59 +63,7 @@ def __init__(self, *args, jurisdiction=None, **kwargs):
city = forms.CharField(
max_length=100, widget=forms.TextInput(attrs={"class": "form-control", "id": "city", "required": "required"})
)
STATE_CHOICES = [
("", "Select a state"),
("AL", "Alabama"),
("AK", "Alaska"),
("AZ", "Arizona"),
("AR", "Arkansas"),
("CA", "California"),
("CO", "Colorado"),
("CT", "Connecticut"),
("DE", "Delaware"),
("FL", "Florida"),
("GA", "Georgia"),
("HI", "Hawaii"),
("ID", "Idaho"),
("IL", "Illinois"),
("IN", "Indiana"),
("IA", "Iowa"),
("KS", "Kansas"),
("KY", "Kentucky"),
("LA", "Louisiana"),
("ME", "Maine"),
("MD", "Maryland"),
("MA", "Massachusetts"),
("MI", "Michigan"),
("MN", "Minnesota"),
("MS", "Mississippi"),
("MO", "Missouri"),
("MT", "Montana"),
("NE", "Nebraska"),
("NV", "Nevada"),
("NH", "New Hampshire"),
("NJ", "New Jersey"),
("NM", "New Mexico"),
("NY", "New York"),
("NC", "North Carolina"),
("ND", "North Dakota"),
("OH", "Ohio"),
("OK", "Oklahoma"),
("OR", "Oregon"),
("PA", "Pennsylvania"),
("RI", "Rhode Island"),
("SC", "South Carolina"),
("SD", "South Dakota"),
("TN", "Tennessee"),
("TX", "Texas"),
("UT", "Utah"),
("VT", "Vermont"),
("VA", "Virginia"),
("WA", "Washington"),
("WV", "West Virginia"),
("WI", "Wisconsin"),
("WY", "Wyoming"),
]
STATE_CHOICES = [("", "Select a state"), *USPS_STATES.items()]
state = forms.ChoiceField(
choices=STATE_CHOICES,
widget=forms.Select(attrs={"class": "form-select", "id": "state", "required": "required"}),
Expand Down
193 changes: 193 additions & 0 deletions efile_app/efile/services/efsp_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@
import json
import re
from collections.abc import Callable
from urllib.parse import urlencode

from django.urls import reverse

from efile.services.efsp_validation import FIELD_LABELS

# Field names the EFSP uses, in the words the UI uses for them.
_FIELD_LABELS = {
Expand All @@ -53,6 +58,21 @@
# not just what went wrong. Patterns are deliberately specific substrings of
# the proxy's own wording so an unrelated message never matches by accident.
_KNOWN_MESSAGE_HINTS: list[tuple[re.Pattern, Callable[[re.Match], str]]] = [
(
re.compile(r"(?:doesn't|dosesn't|does not) support a state named", re.IGNORECASE),
lambda m: (
"Check the party's state or location against the address dropdown. "
"If the address is not supported, contact the court for filing instructions. "
"Do not choose a different state."
),
),
(
re.compile(r"PersonSurName is required or does not match regular expression", re.IGNORECASE),
lambda m: (
"Check the last names in the party information. If a party is a company or other organization, "
"choose Organization and enter its organization name."
),
),
(
re.compile(r"doesn't allow subsequent filing into non-indexed cases", re.IGNORECASE),
lambda m: (
Expand Down Expand Up @@ -126,6 +146,8 @@ def describe_efsp_error(response) -> str:
if not isinstance(body, dict):
return f"the court's filing service returned status {response.status_code}: {str(body)[:_MAX_RAW_BODY]}"

body = _unwrap_error(body)

problems = [
*(_describe_var(var, missing=False) for var in _var_list(body, "wrong_vars")),
*(_describe_var(var, missing=True) for var in _var_list(body, "required_vars")),
Expand All @@ -134,6 +156,9 @@ def describe_efsp_error(response) -> str:
if problems:
return "the court could not accept this filing: " + "; ".join(problems)

if body.get("name"):
return _describe_var(body, missing=False)

# Some errors do arrive as a plain message.
error = body.get("error") or body.get("message") or body.get("detail")
if error:
Expand All @@ -151,6 +176,14 @@ def describe_efsp_error(response) -> str:
message = f"{error_type}: {description}" if error_type else str(description)
return _with_hint(message)

# The proxy's collector includes general errors alongside wrong_vars.
errors = body.get("errors")
if isinstance(errors, list) and errors:
return "; ".join(
_with_hint(str(item.get("description") or item)) if isinstance(item, dict) else _with_hint(str(item))
for item in errors
)

return f"the court's filing service returned status {response.status_code}"


Expand All @@ -165,6 +198,23 @@ def _describe_var(var, *, missing: bool) -> str:
return ""
current = str(var.get("currentVal") or "").strip()

party_match = _PARTY_FIELD.fullmatch(name)
if party_match and party_match[3] in _PARTY_FIELDS:
collection, index, path = party_match.groups()
field = _PARTY_FIELDS[path]
label = FIELD_LABELS.get(field, field.replace("_", " "))
who = f"{'filing' if collection == 'users' else 'other'} party {int(index) + 1}"
description = str(var.get("description") or "")
length = re.search(r"can't exceed (\d+) characters", description)
regex_length = re.search(r"must match regex: \^\.\{0,(\d+)\}\$", description)
if not length:
length = regex_length
if length:
return f"{label} for {who} must be {length[1]} characters or fewer"
if missing or not current:
return f"{label.lower()} is required for {who}"
return f"{current!r} is not a {label.lower()} the court accepts for {who}"

match = _BUNDLE_FIELD.match(name)
if match:
index, name = match.groups()
Expand All @@ -184,3 +234,146 @@ def _describe_var(var, *, missing: bool) -> str:
if missing or not current:
return f"no {label} was given{where}"
return f"{current!r} is not a {label} this court accepts{where}"


_PARTY_FIELD = re.compile(r"^(users|other_parties)\[(\d+)\]\.(.+)$")
_PARTY_FIELDS = {
"name.first": "first_name",
"name.middle": "middle_name",
"name.last": "last_name",
"name.suffix": "suffix",
# NameDocassembleDeserializer adds name.suffix while already inside name.
"name.name.suffix": "suffix",
"email": "email",
"phone_number": "phone",
"mobile_number": "phone",
"address.address": "address_line_1",
"address.unit": "address_line_2",
"address.city": "city",
"address.state": "state",
"address.zip": "zip_code",
"party_type": "party_type",
}
_STATE_MESSAGE = re.compile(
r"(?:Opposing party|Filing party).*?(?:doesn't|dosesn't|does not) support a state named\s+['\"]?([^\s.'\"]+)",
re.IGNORECASE,
)


def efsp_error_problems(response):
"""Retain field paths instead of making the browser parse rendered prose."""
try:
body = response.json()
except (ValueError, TypeError):
return []
if not isinstance(body, dict):
return []
body = _unwrap_error(body)
problems = []
for key in ("wrong_vars", "required_vars"):
for var in _var_list(body, key):
problems.append(
{"name": str(var.get("name") or ""), "message": _describe_var(var, missing=key == "required_vars")}
)
if body.get("name"):
problems.append({"name": str(body["name"]), "message": _describe_var(body, missing=False)})
return problems


def _unwrap_error(body):
"""InfoCollector wraps a single FilingError as {error: {...}}."""
nested = body.get("error")
return nested if isinstance(nested, dict) else body


def _matching_party(wire, parties):
"""A link is safe only when the rejected payload identifies exactly one row.

Array order is not draft order: users can be someone the filer acts for,
and other_parties excludes those users. Duplicate names remain ambiguous.
"""
name = wire.get("name")
if not isinstance(name, dict) or not name.get("first"):
return None
organization = str(wire.get("person_type") or "").lower() in {"business", "organization"}
matches = []
for party in parties:
if organization:
same = party.organization_name == name.get("first")
else:
same = (
party.first_name == name.get("first")
and party.last_name == (name.get("last") or "")
and party.middle_name == (name.get("middle") or "")
and not party.organization_name
)
if same and party.party_type == (wire.get("party_type") or ""):
matches.append(party)
return matches[0] if len(matches) == 1 else None


def error_actions(draft, payload, problems, *, message=""):
"""Build local, draft-bound edit links only for confidently located fields."""
if draft is None or not isinstance(payload, dict):
return []
parties = list(draft.parties.all())
candidates = list(problems)
# Older proxy wording contains the rejected state, but no field path.
# Locate it only when exactly one outgoing party has that value. The
# message may already carry describe_efsp_error's hint after the state.
match = _STATE_MESSAGE.search(message)
if match:
state = match[1]
locations = []
for collection in ("users", "other_parties"):
rows = payload.get(collection)
if not isinstance(rows, list):
continue
for index, wire in enumerate(rows):
if (
isinstance(wire, dict)
and isinstance(wire.get("address"), dict)
and wire["address"].get("state") == state
):
locations.append(f"{collection}[{index}].address.state")
if len(locations) == 1:
candidates.append({"name": locations[0], "message": "Check this party's state or location."})
actions = []
seen = set()
for problem in candidates:
match = _PARTY_FIELD.fullmatch(str(problem.get("name") or ""))
if not match or match[3] not in _PARTY_FIELDS:
continue
collection, index, path = match.groups()
rows = payload.get(collection)
if not isinstance(rows, list) or int(index) >= len(rows) or not isinstance(rows[int(index)], dict):
continue
wire = rows[int(index)]
party = _matching_party(wire, parties)
if party is None or party.role not in {"filer", "other"}:
continue
field = _PARTY_FIELDS[path]
# The filer's court role is collected on a separate screen, not on
# Your information. Do not offer a field link that cannot focus it.
if party.role == "filer" and field == "party_type":
continue
if field == "first_name" and party.organization_name:
field = "organization_name"
if (party.pk, field) in seen:
continue
seen.add((party.pk, field))
view = "your_information" if party.role == "filer" else "party_details"
params = {"draft": draft.pk, "return_to": "review", "focus": field}
if party.role != "filer":
params["party"] = party.pk
url = reverse(view, kwargs={"jurisdiction": draft.jurisdiction}) + "?" + urlencode(params)
label = FIELD_LABELS.get(field, field.replace("_", " "))
display_name = party.organization_name or " ".join(filter(None, [party.first_name, party.last_name]))
actions.append(
{
"url": url,
"label": f"Edit {label.lower()} for {display_name}",
"message": problem.get("message") or f"Check {label.lower()}.",
}
)
return actions
Loading
Loading