Skip to content
28 changes: 28 additions & 0 deletions efile_app/efile/migrations/0019_filing_party.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import migrations, models


def mark_existing_filers(apps, schema_editor):
"""Every draft made before this field existed filed as the filer's own party.

The filer's row was the only thing that could be a filing party then, and
the parties screen refused to continue until it had a party type, so a
draft mid-flight is answered exactly by that rule.
"""

FilingParty = apps.get_model("efile", "FilingParty")
FilingParty.objects.filter(role="filer").exclude(party_type="").update(is_filing_party=True)


class Migration(migrations.Migration):
dependencies = [
("efile", "0018_remembered_ai_choice"),
]

operations = [
migrations.AddField(
model_name="filingparty",
name="is_filing_party",
field=models.BooleanField(default=False),
),
migrations.RunPython(mark_existing_filers, migrations.RunPython.noop),
]
18 changes: 18 additions & 0 deletions efile_app/efile/migrations/0020_notice_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-09-02 00:40

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('efile', '0019_filing_party'),
]

operations = [
migrations.AddField(
model_name='filingdraft',
name='notice_email',
field=models.EmailField(blank=True, max_length=254),
),
]
18 changes: 18 additions & 0 deletions efile_app/efile/migrations/0021_party_is_self.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-09-02 02:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('efile', '0020_notice_email'),
]

operations = [
migrations.AddField(
model_name='filingparty',
name='is_self',
field=models.BooleanField(default=False),
),
]
24 changes: 24 additions & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ class Status(models.TextChoices):
docket_number = models.CharField(max_length=255, blank=True)
case_title = models.CharField(max_length=500, blank=True)

# Where notices about this case should go. Only asked of someone filing
# for a party they are not, because only then are the two addresses
# different questions: the account signed in belongs to the person doing
# the filing, and the notices belong to whoever is meant to read them --
# them, the party, or a relative handling the mail. Blank means the
# filer's own account address, which is what it is offered filled in with.
notice_email = models.EmailField(blank=True)

selected_payment_account_id = models.CharField(max_length=255, blank=True)
selected_payment_account_name = models.CharField(max_length=255, blank=True)
# Tyler's paymentAccountTypeCode for the selected account (e.g. "WV" for a fee
Expand Down Expand Up @@ -390,6 +398,22 @@ class FilingParty(models.Model):
party_type_name = models.CharField(max_length=255, blank=True)
external_party_id = models.CharField(max_length=255, blank=True)

# Set on the review screen, where the people read off the document are
# first shown: "this one is me". It is recorded on the party rather than
# on the filer because the filer has no row of their own yet -- theirs is
# made two screens later, on your-information -- and the parties screen
# is where the two are finally put together.
is_self = models.BooleanField(default=False)

# Whether the filing is made *on behalf of* this party -- what Tyler calls
# a filing party, and a different question from who is in the case. The
# person signed in is usually a party themselves, and then their own row
# carries this. But someone can file for a party they are not: a parent
# for a child, a friend helping a neighbour answer an eviction. Their row
# then has no ``party_type`` at all, and the party they are filing for
# carries this instead. See ``efile.services.people.filing_parties``.
is_filing_party = models.BooleanField(default=False)

# Which side of the caption this person is on, in the only vocabulary a
# document itself establishes: whoever started the case, whoever is
# answering it, or someone else it names. Unlike ``party_type`` -- a code
Expand Down
5 changes: 5 additions & 0 deletions efile_app/efile/services/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
_put(data, "previous_case_id", draft.previous_case_id)
_put(data, "docket_number", draft.docket_number)
_put(data, "case_title", draft.case_title)
_put(data, "notice_email", draft.notice_email)
_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 []))
Expand Down Expand Up @@ -309,6 +310,10 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]:
{
"id": party.pk,
"role": party.role,
# Whether the filing is made on behalf of this party -- the filer
# themselves when they are one, someone they are filing for when
# they are not. The payload names these as Tyler's filing parties.
"is_filing_party": party.is_filing_party,
"party_type": party.party_type,
"party_type_name": party.party_type_name,
"first_name": party.first_name,
Expand Down
110 changes: 105 additions & 5 deletions efile_app/efile/services/extracted_parties.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,64 @@
# filer can correct on the party screen.
_NAME_SUFFIXES = frozenset({"jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v", "esq", "esq."})

# Answers that are not names. A model told to list the parties will sometimes
# say that it could not find any, in the field where the names were supposed
# to go, and the filer then has to work out that "Unknown" is not a person
# they need to keep.
#
# Matched against the whole name and never against part of one, because the
# words themselves are ordinary in real party names: an eviction complaint
# genuinely names "All Unknown Occupants", and a case genuinely has a party
# called "None Smith" more often than this list should get to decide.
_NOT_A_NAME = frozenset(
{
"unknown",
"unknown party",
"unknown parties",
"unknown name",
"name unknown",
"no name",
"none",
"none listed",
"none given",
"none stated",
"n a",
"na",
"not applicable",
"not available",
"not given",
"not listed",
"not named",
"not provided",
"not specified",
"not stated",
"no parties listed",
"no other parties",
"tbd",
"to be determined",
"et al",
"same as above",
"see above",
"see attached",
"see caption",
"null",
"blank",
"empty",
"unspecified",
"unnamed",
"party",
"parties",
"plaintiff",
"plaintiffs",
"petitioner",
"petitioners",
"defendant",
"defendants",
"respondent",
"respondents",
}
)


def looks_like_organization(name: str) -> bool:
lowered = str(name or "").lower()
Expand All @@ -103,6 +161,16 @@ def split_person_name(name: str) -> dict[str, str]:
return {"first_name": tokens[0], "middle_name": " ".join(tokens[1:-1]), "last_name": tokens[-1]}


def is_placeholder_name(name: str) -> bool:
"""True for an answer that says there is no name, rather than giving one.

Whole-name comparison only. "All Unknown Occupants" is a real defendant in
a real eviction, and a guard that reached inside names would delete them.
"""

return _comparable_name(name) in _NOT_A_NAME


def split_extracted_names(value: Any) -> list[dict[str, str]]:
"""Split one extraction field into ``{"name", "role_hint"}`` entries."""

Expand All @@ -121,18 +189,26 @@ def split_extracted_names(value: Any) -> list[dict[str, str]]:
if match and match.group("name").strip():
role_hint = (match.group("paren") or match.group("dash") or "").strip()
text = match.group("name").strip()
if is_placeholder_name(text):
continue
entries.append({"name": text, "role_hint": role_hint})
return entries


def extracted_party_suggestions(guesses: dict[str, Any] | None) -> list[dict[str, str]]:
"""Every person the document named, in caption order, with their side."""
"""Every person the document named, in caption order, with their side.

A name is listed once however many times the document printed it. The
same person read onto two sides is a misreading rather than two people,
so the first side -- caption order, which runs from the initiating side
down -- is the one that survives.
"""

suggestions: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
seen: set[str] = set()
for key, side in SIDE_BY_GUESS_KEY.items():
for entry in split_extracted_names((guesses or {}).get(key)):
fingerprint = (side, _comparable_name(entry["name"]))
fingerprint = _comparable_name(entry["name"])
if fingerprint in seen:
continue
seen.add(fingerprint)
Expand Down Expand Up @@ -181,11 +257,21 @@ def review_rows(draft: FilingDraft) -> list[dict[str, Any]]:
"side": party.party_side or side_for_party_type_name(party.party_type_name),
"role_hint": party.party_role_hint,
"party_type_name": party.party_type_name,
"is_self": party.is_self,
"is_organization": bool(party.organization_name),
}
for party in saved
]
return [
{"id": "", "name": entry["name"], "side": entry["side"], "role_hint": entry["role_hint"], "party_type_name": ""}
{
"id": "",
"name": entry["name"],
"side": entry["side"],
"role_hint": entry["role_hint"],
"party_type_name": "",
"is_self": False,
"is_organization": looks_like_organization(entry["name"]),
}
for entry in extracted_party_suggestions(draft.extracted_guesses)
]

Expand All @@ -202,6 +288,8 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non
existing = {party.pk: party for party in FilingParty.objects.filter(draft=draft, role="other")}
kept: set[int] = set()
next_order = max((party.sort_order for party in existing.values()), default=-1) + 1
# Only one of them can be the person filing, however many rows say so.
claimed_self = False

for index, row in enumerate(rows):
name = row.get("name", "").strip()
Expand All @@ -212,7 +300,9 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non
party = existing.get(row.get("id"))

if party is None:
if not name:
# "Unknown" is the document extraction saying it found nobody, not
# a person to add to the case.
if not name or is_placeholder_name(name):
continue
party = FilingParty(draft=draft, role="other", sort_order=next_order + index)
else:
Expand All @@ -227,6 +317,16 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non
party.party_type_name = ""
party.party_side = side
party.party_role_hint = role_hint
# An organization can never be the person signed in, whose account is
# registered to an individual -- the rule
# ``people.party_can_be_the_filer`` enforces everywhere else, applied
# here too so the answer is never *stored* on a row that could only be
# ignored later. It also keeps a company off the one self slot: a name
# typed into the same submit that ticked it is only known to be a
# company by the time ``apply_name`` above has run.
is_self = str(row.get("is_self", "")).lower() == "true" and not party.organization_name and not claimed_self
claimed_self = claimed_self or is_self
party.is_self = is_self
party.save()

for pk, party in existing.items():
Expand Down
Loading