Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8d269e9
Add people and case question flow
nonprofittechy Aug 10, 2026
381a56b
Normalize reorganized flow CSS formatting
nonprofittechy Aug 10, 2026
60576e0
Address remaining Copilot review comments on people flow
nonprofittechy Aug 10, 2026
a1f687a
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
700f6eb
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
94c25ff
People flow: return to review after an edit instead of replaying the …
nonprofittechy Aug 11, 2026
2d8ceb9
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
7b70b46
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
7afba00
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
c057c2f
Suggest the filer's party type from case posture, as a one-click hint
nonprofittechy Aug 11, 2026
69f936b
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
f0b7ff1
Make name suffix a court-fed dropdown instead of free text
nonprofittechy Aug 11, 2026
9bf2f20
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
fe85948
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
ee83395
Ask for amount in controversy when a document's filing type requires it
nonprofittechy Aug 11, 2026
9a6f0ec
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
6d33ec5
Merge remote-tracking branch 'origin/workflow/document-prep' into wor…
nonprofittechy Aug 11, 2026
5f12367
Fix test: give the routing test a complete required "other" party
nonprofittechy Aug 11, 2026
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.
Binary file added docs/screenshots/reorganized-flow/09-parties.png
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions efile_app/efile/migrations/0006_filingparty_party_type_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.db import migrations, models


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

operations = [
migrations.AddField(
model_name="filingparty",
name="party_type_name",
field=models.CharField(blank=True, default="", max_length=255),
preserve_default=False,
),
]
1 change: 1 addition & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ class FilingParty(models.Model):
sort_order = models.PositiveIntegerField(default=0)

party_type = models.CharField(max_length=100, blank=True)
party_type_name = models.CharField(max_length=255, blank=True)
external_party_id = models.CharField(max_length=255, blank=True)

first_name = models.CharField(max_length=100, blank=True)
Expand Down
34 changes: 34 additions & 0 deletions efile_app/efile/services/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,16 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
_put(data, "reason_for_change", draft.name_change_reason)

for party in FilingParty.objects.filter(draft=draft):
if party.role == "filer":
_put(data, "petitioner_first_name", party.first_name)
_put(data, "petitioner_last_name", party.last_name)
_put(data, "petitioner_address", party.address_line_1)
_put(data, "petitioner_email", party.email)
_put(data, "petitioner_phone", party.phone)
if party.party_type:
for key in _PETITIONER_PARTY_TYPE_KEYS:
_put(data, key, party.party_type)
continue
spec = _PARTY_SPECS.get(party.role)
if spec is None:
continue
Expand All @@ -287,6 +297,30 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
for key in _PETITIONER_PARTY_TYPE_KEYS:
_put(data, key, party.party_type)

filing_parties = [
{
"id": party.pk,
"role": party.role,
"party_type": party.party_type,
"party_type_name": party.party_type_name,
"first_name": party.first_name,
"middle_name": party.middle_name,
"last_name": party.last_name,
"suffix": party.suffix,
"organization_name": party.organization_name,
"email": party.email,
"phone": party.phone,
"address_line_1": party.address_line_1,
"address_line_2": party.address_line_2,
"city": party.city,
"state": party.state,
"zip_code": party.zip_code,
"country": party.country,
}
for party in FilingParty.objects.filter(draft=draft)
]
_put(data, "filing_parties", filing_parties)

# Supplemental answers are emitted as stored (a False/0 answer is meaningful).
for key, value in (draft.supplemental_fields or {}).items():
data[key] = value
Expand Down
156 changes: 156 additions & 0 deletions efile_app/efile/services/people.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from __future__ import annotations

import logging
from typing import Any

import requests
from django.conf import settings

from efile.models import FilingDocument, FilingDraft, FilingParty
from efile.utils.config_loader import config_loader
from efile.workflow import ExistingCase

logger = logging.getLogger(__name__)

# Keywords matched against a court's own party-type names (e.g.
# "Plaintiff/Petitioner") to turn a case-posture guess into one of that
# court's actual codes.
_INITIATING_PARTY_KEYWORDS = ("plaintiff", "petitioner")
_RESPONDING_PARTY_KEYWORDS = ("defendant", "respondent")


def party_is_complete(party: FilingParty) -> bool:
has_name = bool(party.organization_name or (party.first_name and party.last_name))
has_address = bool(party.address_line_1 and party.city and party.state and party.zip_code)
return bool(party.party_type and has_name and has_address)


def incomplete_parties(draft: FilingDraft):
return [party for party in FilingParty.objects.filter(draft=draft) if not party_is_complete(party)]


def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]:
if not draft.court_code or not draft.case_type_code:
return []
url = (
f"{settings.EFSP_URL}/jurisdictions/{draft.jurisdiction}/codes/courts/"
f"{draft.court_code}/case_types/{draft.case_type_code}/party_types"
)
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError):
logger.warning("Could not load party types for draft %s", draft.pk)
return []
if not isinstance(data, list):
return []
return [
{
"code": str(item.get("code") or ""),
"name": str(item.get("name") or ""),
"required": str(item.get("isrequired", "")).lower() == "true" or item.get("isrequired") is True,
}
for item in data
if isinstance(item, dict) and item.get("code") and item.get("name")
]


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.
"""
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

for party_type in party_types:
name = party_type["name"].lower()
if any(keyword in name for keyword in keywords):
return party_type
return None


def ensure_required_parties(draft: FilingDraft, party_types: list[dict[str, Any]]) -> None:
parties = FilingParty.objects.filter(draft=draft)
covered = set(parties.exclude(party_type="").values_list("party_type", flat=True))
last_order = parties.filter(role="other").order_by("-sort_order").values_list("sort_order", flat=True).first()
next_order = 0 if last_order is None else last_order + 1
for party_type in party_types:
code = party_type["code"]
if not party_type["required"] or code in covered:
continue
FilingParty.objects.create(
draft=draft,
role="other",
sort_order=next_order,
party_type=code,
party_type_name=party_type["name"],
)
covered.add(code)
next_order += 1


def needs_amount_in_controversy(draft: FilingDraft) -> bool:
"""True if any organized document's filing type requires a dollar amount.

Tyler flags this per filing type (FilingCode.amountincontroversy ==
"Required"), recorded onto FilingDocument when the filer picks it in
organize_documents. The EFSP rejects the whole filing without it.
"""
return FilingDocument.objects.filter(draft=draft, filing_requires_amount_in_controversy=True).exists()


def get_case_questions(draft: FilingDraft) -> list[dict[str, Any]]:
case_type = draft.case_type_name or draft.case_type_code
lowered = case_type.lower()
if "dissolution" in lowered or "divorce" in lowered:
case_type = "dissolution"
config = config_loader.get_case_type_config(
draft.jurisdiction,
case_type,
court=draft.court_code,
)
questions: list[dict[str, Any]] = []
for section in (config or {}).get("sections", {}).values():
for group in section.get("fields", []):
for field in group.get("fields", []):
name = field.get("name", "")
if name not in {"has_children", "child_count"}:
continue
questions.append(
{
"name": name,
"label": field.get("label") or name.replace("_", " ").title(),
"type": field.get("type", "text"),
"required": bool(field.get("required")),
"options": field.get("options", []),
"min": field.get("min"),
"max": field.get("max"),
"group": group.get("section_title", "Case details"),
}
)
return questions


def parse_question_answer(question: dict[str, Any], value: Any) -> Any:
if question["type"] == "radio":
if str(value).lower() == "true":
return True
if str(value).lower() == "false":
return False
if question["type"] == "number" and value not in (None, ""):
return int(value)
return str(value or "").strip()
Loading