Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 48 additions & 1 deletion efile_app/efile/api/dropdown_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ def prioritize_options(api_data, guessed):
if isinstance(api_data, list):
for opt in api_data:
if isinstance(opt, dict) and "code" in opt and "name" in opt:
options.append({"value": opt["code"], "text": opt["name"]})
# Keep other fields the court sends (e.g. "amountincontroversy" on
# filing types) available to callers that need more than value/text,
# without every caller having to know the raw Tyler field names.
extra = {key: value for key, value in opt.items() if key not in ("code", "name")}
options.append({"value": opt["code"], "text": opt["name"], **extra})
options.sort(key=lambda x: x["text"])

if not guessed:
Expand Down Expand Up @@ -534,6 +538,48 @@ def get_document_types(request):
except Exception as e:
return DropdownAPIViews.error_response(f"Error: {str(e)}")

@staticmethod
@require_http_methods(["GET"])
def get_name_suffixes(request):
"""Get the court's accepted name suffixes (Jr., Sr., II, ...).

A suffix has to exactly match one of these for Tyler to accept the
party -- it isn't free text, even though it looks like it could be.
"""
try:
court_code = request.GET.get("court")
jurisdiction = get_jurisdiction_from_request(request)

if not jurisdiction:
return DropdownAPIViews.error_response("Missing required jurisdiction parameter")

if not court_code:
return DropdownAPIViews.error_response("Missing required court parameter")

api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/{court_code}/name_suffixes"
logger.debug("GET %s", api_url)
response = requests.get(api_url, timeout=10)

if response.status_code == 200:
api_data = response.json()
suffixes = (
[
{"value": item["code"], "text": item["name"]}
for item in api_data
if isinstance(item, dict) and item.get("code") and item.get("name")
]
if isinstance(api_data, list)
else []
)
return DropdownAPIViews.success_response(suffixes)
else:
return DropdownAPIViews.error_response(f"API request failed with status {response.status_code}")

except (requests.RequestException, requests.Timeout) as api_error:
return DropdownAPIViews.error_response(f"API request failed: {str(api_error)}")
except Exception as e:
return DropdownAPIViews.error_response(f"Error: {str(e)}")

@staticmethod
@require_http_methods(["GET"])
def get_optional_services(request):
Expand Down Expand Up @@ -685,3 +731,4 @@ def get_party_types(request):
get_document_types = DropdownAPIViews.get_document_types
get_optional_services = DropdownAPIViews.get_optional_services
get_party_types = DropdownAPIViews.get_party_types
get_name_suffixes = DropdownAPIViews.get_name_suffixes
2 changes: 2 additions & 0 deletions efile_app/efile/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
get_courts,
get_document_types,
get_filing_types,
get_name_suffixes,
get_optional_services,
get_party_types,
)
Expand Down Expand Up @@ -47,6 +48,7 @@
path("dropdowns/document-types/", get_document_types, name="document_types"),
path("dropdowns/optional-services/", get_optional_services, name="optional_services"),
path("dropdowns/party-types/", get_party_types, name="party_types"),
path("dropdowns/name-suffixes/", get_name_suffixes, name="name_suffixes"),
# Form configuration endpoints
path("form-config/", get_form_config, name="form_config"),
path("case-type-config/", get_case_type_config, name="case_type_config"),
Expand Down
13 changes: 13 additions & 0 deletions efile_app/efile/migrations/0005_document_checklist_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [("efile", "0004_filingdraft_case_title")]

operations = [
migrations.AddField(
model_name="filingdraft",
name="document_checklist_acknowledged",
field=models.BooleanField(default=False),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [("efile", "0005_document_checklist_state")]

operations = [
migrations.AddField(
model_name="filingdocument",
name="requested_optional_services",
field=models.JSONField(default=list, blank=True),
),
]
18 changes: 18 additions & 0 deletions efile_app/efile/migrations/0007_amount_in_controversy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [("efile", "0006_filingdocument_requested_optional_services")]

operations = [
migrations.AddField(
model_name="filingdraft",
name="amount_in_controversy",
field=models.CharField(max_length=50, blank=True),
),
migrations.AddField(
model_name="filingdocument",
name="filing_requires_amount_in_controversy",
field=models.BooleanField(default=False),
),
]
15 changes: 15 additions & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ 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 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
# computed on.
amount_in_controversy = models.CharField(max_length=50, blank=True)
# Area-of-law questionnaire answers (e.g. divorce children questions). These are
# driven by the per-state/case-type config, not a fixed schema, so they live in a
# structured JSON field rather than a column each. Only config-defined keys are
Expand Down Expand Up @@ -144,8 +150,17 @@ class Role(models.TextChoices):
document_type_name = models.CharField(max_length=255, blank=True)
filing_component_code = models.CharField(max_length=100, blank=True)
filing_component_name = models.CharField(max_length=255, blank=True)
# The court's own "amountincontroversy" flag for this document's filing
# type (from the filing-types codes API) is "Required" for some case
# types. Recorded per document, since each can carry a different filing
# type; case_questions asks for the dollar amount if any document needs it.
filing_requires_amount_in_controversy = models.BooleanField(default=False)

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
# filing type. See efile.api.dropdown_views.get_optional_services.
requested_optional_services = models.JSONField(default=list, blank=True)

created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Expand Down
93 changes: 93 additions & 0 deletions efile_app/efile/services/document_uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging
import os
from tempfile import NamedTemporaryFile

from efile.models import FilingDocument
from efile.services.drafts import read_upload_data, write_upload_data
from efile.utils.llms import LlmError, extract_fields_from_file
from efile.utils.s3_upload_handler import S3UploadHandler
from efile.views.session_api import llm_fields, llm_hints
from efile.workflow import WorkflowStepKey

logger = logging.getLogger(__name__)


def _analyze_lead(uploaded_file, jurisdiction):
temp_path = None
try:
uploaded_file.seek(0)
with NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
for chunk in uploaded_file.chunks():
temp_file.write(chunk)
temp_path = temp_file.name
return extract_fields_from_file(
temp_path,
llm_fields.get(jurisdiction, llm_fields["default"]),
llm_hint=llm_hints.get(jurisdiction, llm_hints["default"]),
)
except LlmError:
logger.exception("Document extraction failed")
return {}
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
logger.warning("Could not remove extraction temp file %s", temp_path)


def _guess_payload(found_fields):
return {
"court": found_fields.get("court name"),
"filing type": found_fields.get("filing type"),
"case category": found_fields.get("case category"),
"case type": found_fields.get("case type"),
"docket number": found_fields.get("docket number") or found_fields.get("docker number"),
}


def upload_files(draft, uploaded_files, jurisdiction, *, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS):
"""Upload PDFs and merge them into the draft without discarding existing files."""

handler = S3UploadHandler()
if not handler._ensure_initialized():
raise ValueError("Document storage is not configured. Please try again later.")

current = read_upload_data(draft)
files = current.setdefault("files", {})
supporting = list(files.get("supporting", []))
found_lead = False

for uploaded_file in uploaded_files:
validation = handler.validate_file(uploaded_file, max_size_mb=10, allowed_types=[".pdf"])
if not validation["valid"]:
raise ValueError(f"{uploaded_file.name}: {validation['error']}")

is_lead = not files.get("lead") and not found_lead
role = FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING

# Analyze the file while it's still open; boto3 closes the fileobj it's given once uploaded.
guesses = _guess_payload(_analyze_lead(uploaded_file, jurisdiction)) if is_lead else None

uploaded_file.seek(0)
result = handler.upload_file(uploaded_file, file_type=role)
if not result["success"]:
raise ValueError(result.get("error", f"Could not upload {uploaded_file.name}."))

file_data = {
"name": uploaded_file.name,
"size": uploaded_file.size,
"type": uploaded_file.content_type,
"url": handler.get_public_url(result["key"]),
"s3_key": result["key"],
}
if is_lead:
files["lead"] = file_data
found_lead = True
current["guesses"] = guesses
else:
supporting.append(file_data)

files["supporting"] = supporting
write_upload_data(draft, current, current_step=current_step)
return current
2 changes: 2 additions & 0 deletions efile_app/efile/services/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
_put(data, "selected_payment_account", draft.selected_payment_account_id)
_put(data, "selected_payment_account_name", draft.selected_payment_account_name)
_put(data, "optional_services", list(draft.optional_services or []))
_put(data, "amount_in_controversy", draft.amount_in_controversy)
_put(data, "reason_for_name_change", draft.name_change_reason)
_put(data, "reason_for_change", draft.name_change_reason)

Expand Down Expand Up @@ -504,6 +505,7 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None:
"selected_payment_account_name": draft.selected_payment_account_name,
"optional_services": draft.optional_services,
"extracted_guesses": draft.extracted_guesses,
"document_checklist_acknowledged": draft.document_checklist_acknowledged,
"supplemental_fields": draft.supplemental_fields,
"document_count": FilingDocument.objects.filter(draft=draft).count(),
"party_count": FilingParty.objects.filter(draft=draft).count(),
Expand Down
80 changes: 79 additions & 1 deletion efile_app/efile/services/efsp_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@
400" and no way to act, while the response says exactly which document is
missing exactly which field. Shared by the fee quote and the submission so both
describe the same rejection the same way.

Some rejections instead arrive as a single free-text sentence (a "Malformed
Interview" body, or a plain ``error`` string) written for a developer reading
the proxy's logs, not a filer. ``_KNOWN_MESSAGE_HINTS`` recognizes the ones
that come up in practice -- lifted from the literal strings the proxy raises,
in ~/EfileProxyServer (see e.g. ``Ecf4Filer.java`` and
``FilingInformationDocassembleJacksonDeserializer.java``) -- and appends a
sentence saying what to actually do about it. Unrecognized messages still pass
through unchanged rather than being hidden.
"""

import json
import re
from collections.abc import Callable

# Field names the EFSP uses, in the words the UI uses for them.
_FIELD_LABELS = {
Expand All @@ -37,6 +47,66 @@

_MAX_RAW_BODY = 300

# (pattern, hint builder) pairs checked in order against a free-text EFSP
# message; the first match wins. Each hint tells the filer what to actually do,
# 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 allow subsequent filing into non-indexed cases", re.IGNORECASE),
lambda m: (
"Go back to the case details step: choose New case and remove the case number, "
"or choose Existing case and look up the case instead."
),
),
(
re.compile(r"needs docket number, but not present", re.IGNORECASE),
lambda m: "Go back to the case details step and provide the court's case number for this existing case.",
),
(
re.compile(r"Document .*? is too big! Must be max (\d+)", re.IGNORECASE),
lambda m: (
f"One of your PDFs is over the court's {int(m.group(1)):,}-byte limit. "
"Compress it or split it into smaller files, then re-upload."
),
),
(
re.compile(r"All Documents combined are too big! Must be max\s*(\d+)", re.IGNORECASE),
lambda m: (
f"Your documents add up to more than the court's {int(m.group(1)):,}-byte combined limit. "
"Remove or compress some documents and try again."
),
),
(
re.compile(r"Need a filing type! FilingTypes are empty", re.IGNORECASE),
lambda m: (
"This court doesn't offer any filing types for that case category and case type "
"together. Go back and double-check the case category, case type, and whether "
"this is a new or existing case."
),
),
(
re.compile(r"Amount in controversy required", re.IGNORECASE),
lambda m: (
"This case type requires an amount in controversy, which this tool doesn't collect yet. "
"Contact the court about filing this case another way."
),
),
]


def _actionable_hint(message: str) -> str | None:
for pattern, build_hint in _KNOWN_MESSAGE_HINTS:
match = pattern.search(message)
if match:
return build_hint(match)
return None


def _with_hint(message: str) -> str:
hint = _actionable_hint(message)
return f"{message} {hint}" if hint else message


def describe_efsp_error(response) -> str:
"""Describe why the EFSP refused ``response``'s request.
Expand Down Expand Up @@ -70,7 +140,15 @@ def describe_efsp_error(response) -> str:
validation_errors = body.get("validation_errors") or body.get("errors")
if validation_errors:
message += f" - Validation errors: {validation_errors}"
return message
return _with_hint(message)

# "Malformed Interview" errors (e.g. a docket number on a case the court has
# no record of) arrive as {"type": ..., "description": ...} instead.
description = body.get("description")
if description:
error_type = str(body.get("type") or "").strip()
message = f"{error_type}: {description}" if error_type else str(description)
return _with_hint(message)

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

Expand Down
Loading