From bf1f29b94848cc16662fae4872d297b0951040cf Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 20:29:05 -0400 Subject: [PATCH 1/8] Let someone file on behalf of a party they are not The parties screen refused to continue until the person signed in gave *themselves* a court party type, so anyone filing for someone else -- a parent for a child, a neighbour helping answer an eviction -- could not get through it at all. Being the filer and being a party are different questions, and Tyler already keeps them apart: `users` is the list of parties a filing is made on behalf of, and `lead_contact` is whoever filed it. FilingParty.is_filing_party records the first of those. The filer's own row keeps the second, its party_type is now optional, and an empty one means they are not in the case at all. The role question offers "I am filing for someone else" alongside the court's party types, and then asks which party the filing is for. Also fixes the same assumption one step later: the payment screen sent a filer with no party type back to the people step, which a filer who is not a party could never satisfy. It now asks whether the filing has a party at all, which is the thing that actually has to be settled. Closes #207 by making "add me as a party" an explicit button rather than a step nobody could skip: * on the party list, for a filer who is not yet a party * on the review page, next to who the filing is on behalf of * on the add-a-person screen, as "Actually, this party is me" -- which drops the blank row instead of writing a second copy of the filer, who is already on the draft with a name and address Drafts answered before the question existed still file as the filer: the migration backfills them, and both filing_parties() and the payload builder fall back to the filer when nothing is marked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- .../efile/migrations/0019_filing_party.py | 28 ++ efile_app/efile/models.py | 9 + efile_app/efile/services/drafts.py | 4 + efile_app/efile/services/people.py | 93 ++++- efile_app/efile/static/js/filing-payload.js | 109 ++++- efile_app/efile/static/js/parties.js | 66 ++- efile_app/efile/templates/efile/parties.html | 68 ++- .../efile/templates/efile/party_details.html | 15 + efile_app/efile/templates/efile/review.html | 16 +- .../efile/tests/test_filing_on_behalf.py | 387 ++++++++++++++++++ efile_app/efile/tests/test_people_flow.py | 3 +- efile_app/efile/utils/ui_text.py | 5 +- efile_app/efile/views/parties.py | 95 ++++- efile_app/efile/views/payment.py | 6 +- efile_app/efile/views/review.py | 8 + efile_app/js-tests/filing-payload.test.js | 151 +++++++ 16 files changed, 1002 insertions(+), 61 deletions(-) create mode 100644 efile_app/efile/migrations/0019_filing_party.py create mode 100644 efile_app/efile/tests/test_filing_on_behalf.py diff --git a/efile_app/efile/migrations/0019_filing_party.py b/efile_app/efile/migrations/0019_filing_party.py new file mode 100644 index 00000000..ce3d49c4 --- /dev/null +++ b/efile_app/efile/migrations/0019_filing_party.py @@ -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), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 17b8e092..df095df1 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -390,6 +390,15 @@ class FilingParty(models.Model): party_type_name = models.CharField(max_length=255, blank=True) external_party_id = models.CharField(max_length=255, blank=True) + # 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 diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 23c16b90..aded59ee 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -309,6 +309,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, diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index d7e835dd..ed6651bf 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -24,9 +24,21 @@ _INITIATING_PARTY_KEYWORDS = PARTY_SIDE_KEYWORDS[PartySide.INITIATING] _RESPONDING_PARTY_KEYWORDS = PARTY_SIDE_KEYWORDS[PartySide.RESPONDING] +# What the parties screen posts when the person filing says they are not one +# of the parties themselves. It can never collide with a court's own code: +# every value is checked against the court's published list before it is +# stored, and this one is deliberately not in any of them. +NOT_A_PARTY = "__not_a_party__" + def party_is_complete(party: FilingParty, *, draft=None, party_types=None) -> bool: has_name = bool(party.organization_name or (party.first_name and party.last_name)) + if getattr(party, "role", "") == "filer" and not party.party_type: + # Someone filing for a party they are not. They have no party type to + # be missing, and no caption address to complete: their name and + # address were collected on their own screen and reach the court as + # the filing's contact rather than as a person in the case. + return has_name address_required = party_address_requirement( draft or getattr(party, "draft", None), party, @@ -44,6 +56,68 @@ def incomplete_parties(draft: FilingDraft, *, party_types=None): ] +def filer_is_party(draft: FilingDraft) -> bool: + """True when the person signed in is one of the parties in the case. + + Being the filer and being a party are two different things. Most people + using this are self-represented and are both, but a parent filing for a + child, or a neighbour helping someone answer an eviction, is neither + named in the caption nor required to be. + """ + + filer = FilingParty.objects.filter(draft=draft, role="filer").first() + return bool(filer and filer.party_type) + + +def filing_parties(draft: FilingDraft) -> list[FilingParty]: + """The parties this filing is made on behalf of. + + Tyler needs at least one and names it on every document in the envelope + (``filing_parties`` in a court bundle). It is the filer's own row when + they are a party, and whoever they named when they are not. + + Falls back to the filer when nothing has been marked: a draft that was + answered before this question existed said only that the filer was a + party, and back then that was the same answer. + """ + + marked = list(FilingParty.objects.filter(draft=draft, is_filing_party=True)) + if marked: + return marked + filer = FilingParty.objects.filter(draft=draft, role="filer").first() + return [filer] if filer is not None and filer.party_type else [] + + +def set_filing_parties(draft: FilingDraft, parties) -> None: + """Record who this filing is on behalf of, and no one else. + + Written as a replacement rather than a toggle: a filer who corrects + "I am the plaintiff" to "I am filing for my daughter" must not leave + themselves behind as a second filing party. + """ + + wanted = {party.pk for party in parties} + for party in FilingParty.objects.filter(draft=draft): + if party.is_filing_party != (party.pk in wanted): + party.is_filing_party = party.pk in wanted + party.save(update_fields=["is_filing_party", "updated_at"]) + + +def filing_party_candidates(draft: FilingDraft) -> list[FilingParty]: + """The parties a filer who is not one could say they are filing for. + + Only parties that have been named: a blank row the court's required-party + rule created is not yet anybody, and choosing it would tell the court + this filing is on behalf of no one. + """ + + return [ + party + for party in FilingParty.objects.filter(draft=draft, role="other").order_by("sort_order", "created_at") + if party_display_name(party) + ] + + def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]: if not draft.court_code or not draft.case_type_code: return [] @@ -230,6 +304,11 @@ def absorb_filer_duplicates(draft: FilingDraft) -> str: reach the court as a second, address-less person of the same name. The side moves across first, so deleting the duplicate does not throw away the document's own answer to the question the filer is being asked. + + Being the one filed for moves across with it. Someone who said they were + filing for a party who turns out to be themselves under a second name is + still filing for that party, and dropping the flag with the row would + leave the envelope on behalf of nobody. """ duplicates = _filer_duplicates(draft) @@ -238,12 +317,22 @@ def absorb_filer_duplicates(draft: FilingDraft) -> str: filer = FilingParty.objects.filter(draft=draft, role="filer").first() side = filer.party_side if filer is not None else "" + files_on_behalf = filer.is_filing_party if filer is not None else False for party in duplicates: side = side or party.party_side or side_for_party_type_name(party.party_type_name) + files_on_behalf = files_on_behalf or party.is_filing_party party.delete() - if filer is not None and side and not filer.party_side: + if filer is None: + return side + updated = [] + if side and not filer.party_side: filer.party_side = side - filer.save(update_fields=["party_side", "updated_at"]) + updated.append("party_side") + if files_on_behalf and not filer.is_filing_party: + filer.is_filing_party = True + updated.append("is_filing_party") + if updated: + filer.save(update_fields=[*updated, "updated_at"]) return side diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index 1b5d8f5a..252ffebb 100644 --- a/efile_app/efile/static/js/filing-payload.js +++ b/efile_app/efile/static/js/filing-payload.js @@ -78,23 +78,44 @@ const FilingPayload = { }; }, - buildEFilingData(userData, caseData, uploadData, paymentAccountID) { - const nameParts = userData.fullName.split(" "); - const firstName = nameParts[0] || ""; - const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ""; - const middleName = nameParts.length > 2 ? nameParts.slice(1, -1).join(" ") : ""; - - const durableParties = caseData.filing_parties || []; - const durableFiler = durableParties.find((party) => party.role === "filer"); - const partyType = durableFiler?.party_type || caseData.determined_party_type || - caseData.petitioner_party_type || caseData.party_type; + /** Split a single display name into the parts Tyler asks for. */ + splitName(fullName) { + const parts = String(fullName || "").split(" "); + return { + firstName: parts[0] || "", + lastName: parts.length > 1 ? parts[parts.length - 1] : "", + middleName: parts.length > 2 ? parts.slice(1, -1).join(" ") : "" + }; + }, - if (!partyType) { - throw new Error('Party type could not be determined. This is required for eFiling.'); + /** + * Who this filing is made on behalf of, out of the draft's own parties. + * + * Being the filer and being a party are different things: someone can + * file for a party they are not (a parent for a child, a neighbour + * helping with an eviction answer), and then it is that party Tyler must + * be told the filing is for. A draft saved before that question existed + * has nothing marked, and back then the filer was the only thing a filing + * party could be. + * + * @returns {Array} the marked parties, or the filer, or nothing at all + */ + resolveFilingParties(durableParties, durableFiler) { + const marked = durableParties.filter((party) => party.is_filing_party && party.party_type); + if (marked.length) { + return marked; } + return durableFiler?.party_type ? [durableFiler] : []; + }, - // Build user object - const mainUser = { + /** The signed-in filer as a party, from the account they confirmed. */ + accountUser(userData, partyType) { + const { + firstName, + middleName, + lastName + } = this.splitName(userData.fullName); + return { mobile_number: userData.phone, phone_number: userData.phone, address: { @@ -120,11 +141,65 @@ const FilingPayload = { }, is_new: true }; + }, + + /** + * A party the filing is made on behalf of, who is not the person filing. + * + * Same shape as the signed-in filer's own entry, because Tyler makes no + * distinction: `users` is the list of filing parties, whoever they are. + * + * @param {Object} party a durable draft party row + * @param {Object} userData the signed-in filer's contact details + */ + filingPartyFromDraft(party, userData) { + const base = this.partyFromDraft(party); + return { + ...base, + mobile_number: base.phone_number, + date_of_birth: "", + is_form_filler: false, + // Tyler rejects a new case whose first filing party has no email. + // Someone filing for another person often does not have one for + // them, and the filer's own address is the honest stand-in: they + // are already this envelope's lead contact, and they are who the + // court would reach about this filing. + email: base.email || userData.email + }; + }, + + buildEFilingData(userData, caseData, uploadData, paymentAccountID) { + const { + firstName, + middleName, + lastName + } = this.splitName(userData.fullName); + + const durableParties = caseData.filing_parties || []; + const durableFiler = durableParties.find((party) => party.role === "filer"); + const partyType = durableFiler?.party_type || caseData.determined_party_type || + caseData.petitioner_party_type || caseData.party_type; + + // A draft older than durable party rows has none of them, and says who + // it is for with `partyType` alone. + const filingParties = this.resolveFilingParties(durableParties, durableFiler); + const filerIsFilingParty = filingParties.length ? + filingParties.some((party) => party.role === "filer") : + Boolean(partyType); + + if (!filingParties.length && !partyType) { + throw new Error('Party type could not be determined. This is required for eFiling.'); + } - const users = [mainUser]; + // The filer's own entry, built only when they are a party themselves. + const mainUser = filerIsFilingParty ? this.accountUser(userData, partyType) : null; + const users = filingParties.length ? + filingParties.map((party) => ( + party.role === "filer" ? mainUser : this.filingPartyFromDraft(party, userData) + )) : [mainUser]; // Add second user if needed for name changes - if (caseData.new_name_party_type) { + if (caseData.new_name_party_type && mainUser) { users.push({ ...mainUser, party_type: caseData.new_name_party_type, @@ -154,7 +229,7 @@ const FilingPayload = { } let other_parties = durableParties - .filter((party) => party.role !== "filer" && party.party_type) + .filter((party) => party.role !== "filer" && party.party_type && !filingParties.includes(party)) .map((party) => this.partyFromDraft(party)); if (other_parties.length === 0 && caseData.other_first_name && caseData.other_party_type) { diff --git a/efile_app/efile/static/js/parties.js b/efile_app/efile/static/js/parties.js index 50dbc3dc..3a5867f4 100644 --- a/efile_app/efile/static/js/parties.js +++ b/efile_app/efile/static/js/parties.js @@ -1,18 +1,60 @@ (function() { - const button = document.getElementById("apply-party-type-guess"); - if (!button) return; + const guessButton = document.getElementById("apply-party-type-guess"); + const roleRadios = Array.from(document.querySelectorAll('input[name="filer_party_type"]')); + const notAParty = document.getElementById("filer-not-a-party"); + const filingFor = document.getElementById("filing-for"); - button.addEventListener("click", () => { - const radio = document.querySelector(`input[name="filer_party_type"][value="${button.dataset.value}"]`); - const hint = document.getElementById("party-type-hint"); - if (hint) hint.hidden = true; - if (radio) { - radio.checked = true; - radio.scrollIntoView({ + function selectRole(value) { + const radio = roleRadios.find((input) => input.value === value); + if (!radio) return; + radio.checked = true; + radio.dispatchEvent(new Event("change", { + bubbles: true + })); + radio.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + radio.focus(); + } + + if (guessButton) { + guessButton.addEventListener("click", () => { + const hint = document.getElementById("party-type-hint"); + if (hint) hint.hidden = true; + selectRole(guessButton.dataset.value); + }); + } + + // "Who are you filing for?" only means anything to someone who has said + // they are not a party themselves, so it follows that answer rather than + // sitting on the screen as a second unexplained question. + if (notAParty && filingFor) { + const syncFilingFor = () => { + filingFor.hidden = !notAParty.checked; + }; + roleRadios.forEach((radio) => radio.addEventListener("change", syncFilingFor)); + syncFilingFor(); + } + + // The party list's "Add me as a party" shortcut: the filer is already on + // this draft with a name and address, so adding themselves is answering + // the role question above, not typing themselves in again. It takes them + // to the question rather than answering it -- which party type they are + // is a legal question, and picking one for them is how a filer ends up + // filed under the wrong role without ever reading it. + const addMe = document.getElementById("add-me-as-party"); + if (addMe) { + addMe.addEventListener("click", () => { + const firstPartyType = roleRadios.find((input) => input !== notAParty); + if (!firstPartyType) return; + if (notAParty) notAParty.checked = false; + if (filingFor) filingFor.hidden = true; + firstPartyType.scrollIntoView({ behavior: "smooth", block: "center" }); - radio.focus(); - } - }); + firstPartyType.focus(); + }); + } })(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index e268feb0..48034e7a 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -10,9 +10,9 @@
{% translate "People" %}

{% translate "Who is involved in this case?" %}

- {% translate "Start with your role. Then we will ask about any other people, one at a time." %} + {% translate "Start with your own part in this case. Then we will ask about any other people, one at a time." %}

-
+ {% csrf_token %} @@ -52,6 +52,49 @@

{% translate "Who is involved in this case?" %}

{% endfor %} + {% if party_types %} + + {% endif %} + + +
+ {% translate "Who are you filing for?" %} +

+ {% translate "The court records every filing as being made on behalf of a party. Choose the person you are filing for." %} +

+
+ {% for candidate in filing_for_candidates %} + + {% empty %} +

+ {% translate "Add the person you are filing for to the party list below first, then come back and choose them here." %} +

+ {% endfor %}
{% if not party_types %} @@ -68,6 +111,13 @@

{% translate "Who is involved in this case?" %}

{% translate "Party list" %}

+ {% if not filer.party_type %} + + {% endif %} {% csrf_token %} @@ -88,9 +138,17 @@

{% translate "Party list" %}

{{ item.party.first_name }} {{ item.party.last_name }} {% endif %} - {% firstof item.party.party_type_name item.party.get_party_side_display "Role not chosen" %} - {% if item.party.role == "filer" %} - · {% translate "You" %} + + {% if item.party.role == "filer" and not item.party.party_type %} + {% translate "You — filing this, but not a party in the case" %} + {% else %} + {% firstof item.party.party_type_name item.party.get_party_side_display "Role not chosen" %} + {% if item.party.role == "filer" %} + · {% translate "You" %} + {% endif %} + {% endif %} + {% if item.party.is_filing_party and item.party.role != "filer" %} + · {% translate "you are filing for them" %} {% endif %} diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index ef7914f8..e057afac 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -208,6 +208,21 @@

+ {% if not party.first_name and not party.organization_name %} + {# Only offered on a party nobody has named yet: past that point this + button would throw away someone's typing. #} +
+ {% csrf_token %} + + + + + + {% translate "We already have your name and address — you only need to choose your role." %} +
+ {% endif %} {% endblock workflow_content %} {% block extra_js %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index 62a8f8b7..f7b79f06 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -112,7 +112,21 @@

{% translate "Your information" %}

{{ filer.phone }} {% endif %} - {{ filer.party_type_name|default:filer.party_type }} + {% if filer.party_type %} + {{ filer.party_type_name|default:filer.party_type }} + {% else %} +

+ {% if filing_for %} + {% blocktranslate with names=filing_for|join:", " %}You are not a party in this case. This filing is on behalf of {{ names }}.{% endblocktranslate %} + {% else %} + {% translate "You are not a party in this case." %} + {% endif %} +

+ + {% translate "Add me as a party" %} + + {% endif %} {% endif %}
diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py new file mode 100644 index 00000000..943a3e63 --- /dev/null +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -0,0 +1,387 @@ +"""Filing for a party you are not. + +Being the person filing and being a party to the case are two different +things. Most people using this are self-represented and are both, but a parent +files for a child, a neighbour helps someone answer an eviction, and neither of +them belongs in the caption. Tyler asks only who a filing is made *on behalf +of*, and keeps the person filing separately as the envelope's lead contact. + +These tests cover the answer being optional, the follow-up question it opens, +and the screens downstream that used to insist the filer was a party. +""" + +import re +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.people import NOT_A_PARTY, filing_parties, party_is_complete +from efile.workflow import ExistingCase, WorkflowStepKey + +PARTIES_URL = reverse("parties", kwargs={"jurisdiction": "illinois"}) +PAYMENT_URL = reverse("payment", kwargs={"jurisdiction": "illinois"}) +REVIEW_URL = reverse("case_review", kwargs={"jurisdiction": "illinois"}) + +PARTY_TYPES = [ + {"code": "plaintiff", "name": "Plaintiff", "required": True}, + {"code": "defendant", "name": "Defendant", "required": True}, +] + + +@pytest.fixture +def draft(client, django_user_model): + """A neighbour, signed in, helping someone else answer an eviction.""" + + user = django_user_model.objects.create_user(username="helper", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + workflow_version=2, + existing_case=ExistingCase.NEW, + court_code="cook:cvd1", + case_type_code="NC", + case_type_name="Name Change", + current_step=WorkflowStepKey.PARTIES, + document_checklist_acknowledged=True, + ) + FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="answer.pdf", + filing_type_code="90001", + filing_type_name="Answer", + document_type_code="public", + ) + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + return draft + + +def make_filer(draft, **overrides): + values = { + "first_name": "Helper", + "last_name": "Neighbor", + "email": "helper@example.com", + "address_line_1": "1 Main Street", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + } + values.update(overrides) + return FilingParty.objects.create(draft=draft, role="filer", sort_order=0, **values) + + +def make_party(draft, sort_order, **overrides): + values = { + "party_type": "defendant", + "party_type_name": "Defendant", + "first_name": "Real", + "last_name": "Tenant", + "address_line_1": "2 Elm Street", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + } + values.update(overrides) + return FilingParty.objects.create(draft=draft, role="other", sort_order=sort_order, **values) + + +def both_sides(draft): + """A roster the court's required party types are already satisfied by.""" + + tenant = make_party(draft, 0) + landlord = make_party( + draft, + 1, + party_type="plaintiff", + party_type_name="Plaintiff", + first_name="", + last_name="", + organization_name="Landlord LLC", + ) + return tenant, landlord + + +def post_parties(client, **data): + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + return client.post(PARTIES_URL, data) + + +# --- Saying you are not a party ---------------------------------------------- + + +@pytest.mark.django_db +def test_a_filer_who_is_not_a_party_names_who_they_are_filing_for(client, draft): + filer = make_filer(draft) + tenant, landlord = both_sides(draft) + + response = post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + + filer.refresh_from_db() + tenant.refresh_from_db() + landlord.refresh_from_db() + draft.refresh_from_db() + assert response.status_code == 302 + # Past the people step rather than back into it. + assert draft.current_step != WorkflowStepKey.PARTIES + assert filer.party_type == "" + assert filer.is_filing_party is False + assert tenant.is_filing_party is True + assert landlord.is_filing_party is False + assert filing_parties(draft) == [tenant] + + +@pytest.mark.django_db +def test_saying_you_are_not_a_party_without_naming_anyone_is_refused(client, draft): + """Tyler needs a party to file on behalf of. Nobody is not an answer.""" + + filer = make_filer(draft) + tenant, _landlord = both_sides(draft) + + response = post_parties(client, filer_party_type=NOT_A_PARTY) + + filer.refresh_from_db() + tenant.refresh_from_db() + assert response.status_code == 200 + assert "Choose who you are filing for." in response.content.decode() + assert tenant.is_filing_party is False + + +@pytest.mark.django_db +def test_a_refused_answer_stays_on_the_screen_with_the_error(client, draft): + """Otherwise the follow-up question hides itself again and the filer has + to work out that they must re-pick the answer they already gave.""" + + make_filer(draft) + both_sides(draft) + + content = post_parties(client, filer_party_type=NOT_A_PARTY).content.decode() + + still_chosen = re.search(rf'value="{NOT_A_PARTY}"[^>]*checked', content) + assert still_chosen is not None + assert re.search(r'id="filing-for"(?![^>]*hidden)', content) is not None + + +@pytest.mark.django_db +def test_an_answer_of_neither_kind_is_still_refused(client, draft): + make_filer(draft) + both_sides(draft) + + response = post_parties(client, filer_party_type="") + + assert response.status_code == 200 + assert "Choose your role in this case" in response.content.decode() + + +@pytest.mark.django_db +def test_someone_can_be_filed_for_by_two_co_parties_at_once(client, draft): + filer = make_filer(draft) + tenant, landlord = both_sides(draft) + + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=[tenant.pk, landlord.pk]) + + assert {party.pk for party in filing_parties(draft)} == {tenant.pk, landlord.pk} + filer.refresh_from_db() + assert filer.is_filing_party is False + + +# --- Changing your mind ------------------------------------------------------ + + +@pytest.mark.django_db +def test_becoming_a_party_takes_the_filing_back_from_who_you_named(client, draft): + """Otherwise the envelope would name two filing parties, one of them stale.""" + + filer = make_filer(draft) + tenant, _landlord = both_sides(draft) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + + post_parties(client, filer_party_type="defendant") + + filer.refresh_from_db() + tenant.refresh_from_db() + assert filer.party_type == "defendant" + assert filer.is_filing_party is True + assert tenant.is_filing_party is False + assert filing_parties(draft) == [filer] + + +@pytest.mark.django_db +def test_filing_for_someone_else_gives_up_your_own_party_type(client, draft): + filer = make_filer(draft, party_type="defendant", party_type_name="Defendant", is_filing_party=True) + tenant, _landlord = both_sides(draft) + + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + + filer.refresh_from_db() + assert filer.party_type == "" + assert filer.party_type_name == "" + assert filer.is_filing_party is False + + +# --- What the screens say ---------------------------------------------------- + + +@pytest.mark.django_db +def test_the_role_question_offers_filing_for_someone_else(client, draft): + make_filer(draft) + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.get(PARTIES_URL) + + content = response.content.decode() + assert f'value="{NOT_A_PARTY}"' in content + assert "I am filing for someone else" in content + assert "Who are you filing for?" in content + + +@pytest.mark.django_db +def test_the_party_list_offers_to_add_you_while_you_are_not_a_party(client, draft): + """Issue #207: adding yourself should be one button, not a retyped form.""" + + make_filer(draft) + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + not_a_party = client.get(PARTIES_URL).content.decode() + + FilingParty.objects.filter(draft=draft, role="filer").update(party_type="defendant") + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + already_a_party = client.get(PARTIES_URL).content.decode() + + assert 'id="add-me-as-party"' in not_a_party + assert 'id="add-me-as-party"' not in already_a_party + + +@pytest.mark.django_db +def test_a_party_nobody_has_named_is_not_offered_as_who_you_are_filing_for(client, draft): + """A blank row the court's required-party rule made is not yet anybody.""" + + make_filer(draft) + named = make_party(draft, 0) + FilingParty.objects.create(draft=draft, role="other", sort_order=1, party_type="plaintiff") + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.get(PARTIES_URL) + + content = response.content.decode() + offered = re.findall(r'name="filing_for"\s+value="(\d+)"', content) + assert offered == [str(named.pk)] + + +@pytest.mark.django_db +def test_the_add_a_person_screen_offers_actually_this_is_me(client, draft): + make_filer(draft) + blank = FilingParty.objects.create(draft=draft, role="other", sort_order=0) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + url = f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={blank.pk}" + response = client.get(url) + + assert "Actually, this party is me" in response.content.decode() + + +@pytest.mark.django_db +def test_saying_a_new_party_is_you_drops_the_row_instead_of_duplicating_you(client, draft): + """The filer is already on the draft once; a second copy of them reaches + the court as two people with the same name.""" + + make_filer(draft) + blank = FilingParty.objects.create(draft=draft, role="other", sort_order=0) + + response = post_parties(client, action="remove", party_id=blank.pk, instead="me") + + assert response.status_code == 302 + assert response.url.endswith("#your-role") + assert not FilingParty.objects.filter(pk=blank.pk).exists() + + +# --- The screens that used to insist ---------------------------------------- + + +@pytest.mark.django_db +def test_a_filer_who_is_not_a_party_is_not_missing_their_own_details(client, draft): + """Their name and address belong to the contact record, not the caption, + and the party-details screen has nothing to ask them for.""" + + filer = make_filer(draft) + + assert party_is_complete(filer, draft=draft, party_types=PARTY_TYPES) is True + + +@pytest.mark.django_db +def test_a_filer_who_is_not_a_party_reaches_payment(client, draft): + filer = make_filer(draft) + tenant, _landlord = both_sides(draft) + tenant.is_filing_party = True + tenant.save(update_fields=["is_filing_party"]) + + response = client.get(PAYMENT_URL) + + assert response.status_code == 200 + assert filer.party_type == "" + + +@pytest.mark.django_db +def test_review_says_who_the_filing_is_for_and_offers_to_add_you(client, draft): + make_filer(draft) + tenant, _landlord = both_sides(draft) + tenant.is_filing_party = True + tenant.save(update_fields=["is_filing_party"]) + draft.selected_payment_account_id = "pay-1" + draft.selected_payment_account_name = "Card" + draft.save(update_fields=["selected_payment_account_id", "selected_payment_account_name"]) + + content = client.get(REVIEW_URL).content.decode() + + assert "You are not a party in this case" in content + assert "Real Tenant" in content + assert "Add me as a party" in content + + +@pytest.mark.django_db +def test_filing_for_someone_who_turns_out_to_be_you_still_files_for_them(client, draft): + """The roster's copy of the filer is folded into the filer's own row. The + envelope has to come out on behalf of someone all the same.""" + + filer = make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera", is_filing_party=True) + make_party( + draft, + 1, + party_type="plaintiff", + party_type_name="Plaintiff", + first_name="", + last_name="", + organization_name="Landlord LLC", + ) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + client.get(PARTIES_URL) + + filer.refresh_from_db() + assert not FilingParty.objects.filter(pk=twin.pk).exists() + assert filing_parties(draft) == [filer] + + +# --- Drafts from before the question existed --------------------------------- + + +@pytest.mark.django_db +def test_a_draft_answered_before_this_question_still_files_as_the_filer(client, draft): + """Back then a filer with a party type was the only possible filing party, + so that is what their unmarked draft still means.""" + + filer = make_filer(draft, party_type="defendant", party_type_name="Defendant") + + assert filer.is_filing_party is False + assert filing_parties(draft) == [filer] diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index 4e79efca..ad07662b 100644 --- a/efile_app/efile/tests/test_people_flow.py +++ b/efile_app/efile/tests/test_people_flow.py @@ -752,4 +752,5 @@ def test_the_role_question_is_asked_the_way_other_primary_questions_are(client, content = response.content.decode() assert 'class="form-field primary-question"' in content assert "What is your role in this case?" in content - assert "The court uses it to list you as a party" in content + assert "list you as a party" in content + assert "if you are filing for someone else" in content diff --git a/efile_app/efile/utils/ui_text.py b/efile_app/efile/utils/ui_text.py index 5a7e8323..21a518d9 100644 --- a/efile_app/efile/utils/ui_text.py +++ b/efile_app/efile/utils/ui_text.py @@ -186,7 +186,10 @@ class UIString: default="What is your role in this case?", ), "parties.role_help": UIString( - default="Choose the role that describes you. The court uses it to list you as a party in this case.", + default=( + "Choose the role that describes you, and the court will list you as a party in this case. " + "You do not have to be one: if you are filing for someone else, say so and we will ask who." + ), ), # -- Public pages -------------------------------------------------------- "about.project_partner_description": UIString( diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 29dc1a8f..20866a88 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -8,15 +8,18 @@ from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot from efile.services.people import ( + NOT_A_PARTY, absorb_filer_duplicates, apply_party_sides, ensure_required_parties, + filing_party_candidates, get_case_questions, get_party_types, guess_filer_party_type, incomplete_parties, needs_amount_in_controversy, party_is_complete, + set_filing_parties, ) from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to @@ -30,6 +33,38 @@ def _parties_url(jurisdiction, return_to=None): return with_return_to(reverse("parties", kwargs={"jurisdiction": jurisdiction}), return_to) +def _chosen_filing_parties(request, draft): + """The roster rows the filer ticked as the people they are filing for.""" + + ids = [value for value in request.POST.getlist("filing_for") if str(value).isdigit()] + if not ids: + return [] + return list(FilingParty.objects.filter(draft=draft, role="other", pk__in=ids)) + + +def _continue_from_parties(request, jurisdiction, draft, party_types, return_to): + """Fill in the court's required parties, then move on or collect the gaps.""" + + ensure_required_parties(draft, party_types) + incomplete = incomplete_parties(draft, party_types=party_types) + if incomplete: + draft.current_step = WorkflowStepKey.PARTY_DETAILS + draft.save(update_fields=["current_step", "updated_at"]) + return redirect(_party_details_url(jurisdiction, incomplete[0], return_to)) + + has_questions = bool(get_case_questions(draft)) or needs_amount_in_controversy(draft) + draft.supplemental_fields = { + **(draft.supplemental_fields or {}), + "_case_questions_required": has_questions, + } + if return_to == RETURN_TO_REVIEW: + draft.current_step = WorkflowStepKey.REVIEW + else: + draft.current_step = WorkflowStepKey.CASE_QUESTIONS if has_questions else WorkflowStepKey.PAYMENT + draft.save(update_fields=["supplemental_fields", "current_step", "updated_at"]) + return redirect(get_step_url(draft.current_step, jurisdiction)) + + @require_http_methods(["GET", "POST"]) def parties(request, jurisdiction): if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -73,40 +108,53 @@ def parties(request, jurisdiction): if action == "remove": party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") party.delete() + if request.POST.get("instead") == "me": + # "Actually, this is me" on the add-a-person screen. The blank + # row goes, because the filer is already on this draft once and + # a second copy of them would reach the court as two people. + messages.success(request, "Choose your own role below to add yourself as a party.") + return redirect(f"{_parties_url(jurisdiction, return_to)}#your-role") messages.success(request, "Party removed.") return redirect(_parties_url(jurisdiction, return_to)) filer_type = request.POST.get("filer_party_type", "").strip() - if not filer_type or filer_type not in party_type_names: - messages.error(request, "Choose your role in this case.") - else: + if filer_type == NOT_A_PARTY: + # Filing for someone else. Tyler still needs a party to file on + # behalf of, so the filer names one instead of becoming one. + chosen = _chosen_filing_parties(request, draft) + if not chosen: + messages.error(request, "Choose who you are filing for.") + else: + filer.party_type = "" + filer.party_type_name = "" + filer.save(update_fields=["party_type", "party_type_name", "updated_at"]) + set_filing_parties(draft, chosen) + return _continue_from_parties(request, jurisdiction, draft, party_types, return_to) + elif filer_type in party_type_names: filer.party_type = filer_type filer.party_type_name = party_type_names.get(filer_type, filer.party_type_name) filer.save(update_fields=["party_type", "party_type_name", "updated_at"]) - ensure_required_parties(draft, party_types) - incomplete = incomplete_parties(draft, party_types=party_types) - if incomplete: - draft.current_step = WorkflowStepKey.PARTY_DETAILS - draft.save(update_fields=["current_step", "updated_at"]) - return redirect(_party_details_url(jurisdiction, incomplete[0], return_to)) - - has_questions = bool(get_case_questions(draft)) or needs_amount_in_controversy(draft) - draft.supplemental_fields = { - **(draft.supplemental_fields or {}), - "_case_questions_required": has_questions, - } - if return_to == RETURN_TO_REVIEW: - draft.current_step = WorkflowStepKey.REVIEW - else: - draft.current_step = WorkflowStepKey.CASE_QUESTIONS if has_questions else WorkflowStepKey.PAYMENT - draft.save(update_fields=["supplemental_fields", "current_step", "updated_at"]) - return redirect(get_step_url(draft.current_step, jurisdiction)) + set_filing_parties(draft, [filer]) + return _continue_from_parties(request, jurisdiction, draft, party_types, return_to) + else: + messages.error(request, "Choose your role in this case, or tell us you are filing for someone else.") roster = [ {"party": party, "complete": party_is_complete(party, party_types=party_types)} for party in FilingParty.objects.filter(draft=draft) ] guessed_party_type = None if filer.party_type else guess_filer_party_type(draft, party_types) + # Which branch of the role question the screen comes back on. A filer who + # has never answered gets neither pre-selected -- their own role is not + # something to guess at on their behalf -- but an answer that has just + # been refused is still their answer, and stays on the screen with the + # error rather than making them find it again. + saved_filing_for = { + party.pk for party in FilingParty.objects.filter(draft=draft, role="other", is_filing_party=True) + } + attempted = request.POST.get("filer_party_type", "").strip() if request.method == "POST" else "" + attempted_filing_for = {int(value) for value in request.POST.getlist("filing_for") if str(value).isdigit()} + filing_for = attempted_filing_for or saved_filing_for context = { "is_logged_in": True, "filing_draft": draft_snapshot(draft), @@ -115,6 +163,11 @@ def parties(request, jurisdiction): "party_types": party_types, "roster": roster, "guessed_party_type": guessed_party_type, + "not_a_party_value": NOT_A_PARTY, + "filing_for_someone_else": attempted == NOT_A_PARTY or (bool(saved_filing_for) and not filer.party_type), + "filing_for_candidates": [ + {"party": party, "selected": party.pk in filing_for} for party in filing_party_candidates(draft) + ], } context.update(get_workflow_context(WorkflowStepKey.PARTIES, jurisdiction, draft)) return render(request, "efile/parties.html", context) diff --git a/efile_app/efile/views/payment.py b/efile_app/efile/views/payment.py index fca982b7..c110fcd8 100644 --- a/efile_app/efile/views/payment.py +++ b/efile_app/efile/views/payment.py @@ -9,6 +9,7 @@ from efile.models import FilingDocument, FilingParty from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, read_case_data +from efile.services.people import filing_parties from ..workflow import WorkflowStepKey, get_step_url, get_workflow_context @@ -32,7 +33,10 @@ def efile_payment(request, jurisdiction): messages.error(request, "Add and organize at least one document before choosing payment.") return redirect("upload_documents", jurisdiction=jurisdiction) filer = FilingParty.objects.filter(draft=draft, role="filer").first() - if filer is None or not filer.party_type: + # What has to be settled is who the filing is *for*, not whether the filer + # is a party: someone filing for their child has no party type of their own + # and is no less finished with this step. + if filer is None or not filing_parties(draft): messages.error(request, "Complete the people in this filing before choosing payment.") return redirect("parties", jurisdiction=jurisdiction) diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index 1171b5d7..b1c36562 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -5,6 +5,7 @@ from efile.models import FilingDocument, FilingParty from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, read_case_data, read_upload_data +from efile.services.extracted_parties import party_display_name from efile.services.filing_plans import documents_missing_from_envelope from efile.services.people import get_case_questions @@ -44,6 +45,13 @@ def case_review(request, jurisdiction): "draft": draft, "filer": parties.filter(role="filer").first(), "parties": parties.exclude(role="filer").order_by("sort_order", "created_at"), + # Who the filing is on behalf of, when that is not the filer. Worth + # saying out loud on the last screen before submission: a filing sent + # under the wrong party's name is not something the filer can undo. + "filing_for": [ + party_display_name(party) + for party in parties.filter(is_filing_party=True).exclude(role="filer").order_by("sort_order", "created_at") + ], "documents": FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at"), "question_answers": question_answers, # Everything in one envelope reaches the clerk together. This is the diff --git a/efile_app/js-tests/filing-payload.test.js b/efile_app/js-tests/filing-payload.test.js index 0761523e..373708cc 100644 --- a/efile_app/js-tests/filing-payload.test.js +++ b/efile_app/js-tests/filing-payload.test.js @@ -344,4 +344,155 @@ test("optional services for lead and supporting documents are included in bundle assert.strictEqual(bundles.length, 2); assert.deepStrictEqual(bundles[0].optional_services, ["143487"]); assert.deepStrictEqual(bundles[1].optional_services, ["143491"]); +}); +// -- Filing for a party the filer is not ------------------------------------ +// +// Being the person filing and being a party are different things. Tyler asks +// only who the filing is on behalf of, so a filer who is not a party names +// someone else and stays out of the caption entirely. + +const FILING_FOR_SOMEONE_ELSE = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: false, + party_type: "", + first_name: "Helper", + last_name: "Neighbor", + email: "helper@example.com" + }, { + role: "other", + is_filing_party: true, + party_type: "DEF", + first_name: "Real", + last_name: "Tenant", + email: "tenant@example.com" + }, { + role: "other", + is_filing_party: false, + party_type: "PLA", + organization_name: "Landlord LLC" + }] +}; + +test("a filer who is not a party files on behalf of the party they named", () => { + const handler = makeHandler(); + const userData = handler.userDataFromCaseData(FILING_FOR_SOMEONE_ELSE); + const result = handler.buildEFilingData(userData, FILING_FOR_SOMEONE_ELSE, {}, "pay-1"); + + assert.strictEqual(result.users.length, 1); + assert.strictEqual(result.users[0].name.first, "Real"); + assert.strictEqual(result.users[0].party_type, "DEF"); + // The helper reaches the court as the contact, never as a party. + assert.strictEqual(result.other_parties.length, 1); + assert.strictEqual(result.other_parties[0].name.first, "Landlord LLC"); + assert.strictEqual(result.lead_contact.name.first, "Helper"); + assert.strictEqual(result.lead_contact.email, "helper@example.com"); +}); + +test("the party being filed for is not repeated in other_parties", () => { + const handler = makeHandler(); + const userData = handler.userDataFromCaseData(FILING_FOR_SOMEONE_ELSE); + const result = handler.buildEFilingData(userData, FILING_FOR_SOMEONE_ELSE, {}, "pay-1"); + + const names = result.other_parties.map((party) => party.name.first); + assert.strictEqual(names.includes("Real"), false); +}); + +test("a filing party with no email of their own borrows the filer's", () => { + const handler = makeHandler(); + const caseData = structuredClone(FILING_FOR_SOMEONE_ELSE); + caseData.filing_parties[1].email = ""; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + // Tyler rejects a new case whose first filing party has no email at all. + assert.strictEqual(result.users[0].email, "helper@example.com"); +}); + +test("every document is filed on behalf of the named party, not the filer", () => { + const handler = makeHandler(); + const userData = handler.userDataFromCaseData(FILING_FOR_SOMEONE_ELSE); + const result = handler.buildEFilingData(userData, FILING_FOR_SOMEONE_ELSE, { + files: { + lead: { + name: "answer.pdf" + } + } + }, "pay-1"); + + assert.deepStrictEqual(result.al_court_bundle[0].filing_parties, ["users[0]"]); +}); + +test("a filer who is a party is still the filing party themselves", () => { + const handler = makeHandler(); + const caseData = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: true, + party_type: "PLA", + first_name: "Jordan", + last_name: "Taylor", + email: "jordan@example.com" + }, { + role: "other", + is_filing_party: false, + party_type: "DEF", + first_name: "Alex", + last_name: "Morgan" + }] + }; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual(result.users.length, 1); + assert.strictEqual(result.users[0].name.first, "Jordan"); + assert.strictEqual(result.users[0].party_type, "PLA"); + assert.strictEqual(result.other_parties.length, 1); +}); + +test("co-parties who are both filing are both named as filing parties", () => { + const handler = makeHandler(); + const caseData = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: false, + party_type: "", + first_name: "Helper", + last_name: "Neighbor", + email: "helper@example.com" + }, { + role: "other", + is_filing_party: true, + party_type: "PLA", + first_name: "First", + last_name: "Tenant", + email: "one@example.com" + }, { + role: "other", + is_filing_party: true, + party_type: "PLA", + first_name: "Second", + last_name: "Tenant" + }] + }; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, { + files: { + lead: { + name: "complaint.pdf" + } + } + }, "pay-1"); + + assert.strictEqual(result.users.length, 2); + assert.deepStrictEqual( + result.al_court_bundle[0].filing_parties, + ["users[0]", "users[1]"] + ); }); \ No newline at end of file From 36554e8609b326d3567cac2eb03c5d8ce301080a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 20:51:48 -0400 Subject: [PATCH 2/8] Guard against parties nobody meant to add, and ask where notices go Three things that meet on the parties screen. **"Unknown" is not a person.** A model asked to list the parties will sometimes answer, in the field where the names go, that it could not find any -- and "Unknown", "N/A" and "None" became parties the filer then had to work out how to get rid of. They are dropped now, on a whole-name comparison and never a substring: "All Unknown Occupants" is a real defendant in a real eviction, and a guard that reached inside names would delete them. The same name read onto two sides is also one person misread rather than two people, and is listed once. Two other ways an unnamed party appeared: adding a person makes the row before the form that names them, so leaving that form without saving stranded a nameless entry on the list, which is now cleared on the way back in. A nameless row with a party type is left alone -- that is the court's own required-party placeholder, waiting for a name rather than missing one by accident. **A caption name matching the filer is a question, not an answer.** It used to be folded into the filer silently, which was safe while the filer was always a party. Now that they need not be, deleting a party because they share a name with the person filing takes a real party out of the case -- someone filing for a relative they are named after is exactly the case this screen now exists for. So the fold waits for a filer who has said they are a party, and until then the match is put to them as "your document lists Jamie Rivera as the Defendant -- is this you?", with one button to say yes. It is the strongest suggestion available (the document said which side they were on) so it replaces the vaguer case-posture guess, and it stops being offered once they have answered the role question either way. **Notices need an address that can be someone else's.** Filing for another person, `users[0].email` was quietly filled in with the filer's, because Tyler rejects a new case whose first filing party has no email. Quietly is the problem: it decided where the court writes about someone else's case without asking. FilingDraft.notice_email is that question, asked only of someone filing for a party they are not, offered filled in with their own address and editable to the party's or to whoever handles their mail. It reaches the payload as the lead contact's address and as the filing party's when they have none of their own, and the review screen says where notices go with a link back to change it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- .../efile/migrations/0020_notice_email.py | 18 ++ efile_app/efile/models.py | 8 + efile_app/efile/services/drafts.py | 1 + efile_app/efile/services/extracted_parties.py | 86 ++++++- efile_app/efile/services/people.py | 80 ++++++ efile_app/efile/static/js/filing-payload.js | 26 +- efile_app/efile/templates/efile/parties.html | 29 +++ efile_app/efile/templates/efile/review.html | 7 + .../efile/tests/test_extracted_parties.py | 108 +++++++- .../efile/tests/test_filing_on_behalf.py | 239 ++++++++++++++++-- efile_app/efile/views/parties.py | 69 ++++- efile_app/efile/views/review.py | 4 + efile_app/js-tests/filing-payload.test.js | 32 +++ 13 files changed, 669 insertions(+), 38 deletions(-) create mode 100644 efile_app/efile/migrations/0020_notice_email.py diff --git a/efile_app/efile/migrations/0020_notice_email.py b/efile_app/efile/migrations/0020_notice_email.py new file mode 100644 index 00000000..44243b1d --- /dev/null +++ b/efile_app/efile/migrations/0020_notice_email.py @@ -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), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index df095df1..a452e4e7 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -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 diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index aded59ee..74a0990b 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -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 [])) diff --git a/efile_app/efile/services/extracted_parties.py b/efile_app/efile/services/extracted_parties.py index 392d7dfd..aa365804 100644 --- a/efile_app/efile/services/extracted_parties.py +++ b/efile_app/efile/services/extracted_parties.py @@ -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() @@ -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.""" @@ -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) @@ -212,7 +288,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: diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index ed6651bf..9c5be54d 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -103,6 +103,77 @@ def set_filing_parties(draft: FilingDraft, parties) -> None: party.save(update_fields=["is_filing_party", "updated_at"]) +def filer_name_match(draft: FilingDraft) -> FilingParty | None: + """The caption party who has the filer's own name, when they are not one. + + A document names the person filing along with everyone else, so this is + the strongest signal there is that they belong in the case -- stronger + than the case-posture guess, because the document itself said which side + they were on. It is a suggestion and not an answer: two people share a + name often enough, and a parent filing for a child they are named after + is exactly the case this whole screen exists for. The parties screen puts + it to them; :func:`claim_party_as_filer` is what confirming it does. + """ + + if filer_is_party(draft): + return None + matches = _filer_duplicates(draft) + return matches[0] if matches else None + + +def claim_party_as_filer(draft: FilingDraft, party: FilingParty) -> None: + """Answer "yes, that party is me": become them, and stop listing them twice. + + The filer's own row is kept rather than the caption's, because it is the + one with the address and email the court needs, and the caption row is + deleted rather than left as a second person of the same name. + """ + + filer = FilingParty.objects.filter(draft=draft, role="filer").first() + if filer is None: + return + filer.party_type = party.party_type + filer.party_type_name = party.party_type_name + filer.party_side = filer.party_side or party.party_side + filer.party_role_hint = filer.party_role_hint or party.party_role_hint + filer.is_filing_party = True + filer.save( + update_fields=[ + "party_type", + "party_type_name", + "party_side", + "party_role_hint", + "is_filing_party", + "updated_at", + ] + ) + party.delete() + set_filing_parties(draft, [filer]) + + +def discard_empty_parties(draft: FilingDraft) -> int: + """Delete party rows that were started and never filled in. + + Adding a person creates the row before the form that names them, so + leaving that form without saving strands a row with nothing in it. It + reaches the party list as a nameless entry the filer did not add on + purpose and cannot tell apart from one they did. + + A nameless row that carries a party type is left alone: that is the + court's own required-party placeholder, which is waiting for a name + rather than missing one by accident. + """ + + empty = [ + party + for party in FilingParty.objects.filter(draft=draft, role="other", party_type="") + if not party_display_name(party) and not party.party_side + ] + for party in empty: + party.delete() + return len(empty) + + def filing_party_candidates(draft: FilingDraft) -> list[FilingParty]: """The parties a filer who is not one could say they are filing for. @@ -309,8 +380,17 @@ def absorb_filer_duplicates(draft: FilingDraft) -> str: filing for a party who turns out to be themselves under a second name is still filing for that party, and dropping the flag with the row would leave the envelope on behalf of nobody. + + Only ever folds into a filer who has said they are a party. Before that, + a caption name matching theirs is a question rather than an answer -- + they may be that party, or they may be a different person with the same + name, or they may be filing for a relative they share a name with. It is + put to them on the parties screen instead; see :func:`filer_name_match`. """ + if not filer_is_party(draft): + return side_named_for_filer(draft) + duplicates = _filer_duplicates(draft) if not duplicates: return side_named_for_filer(draft) diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index 252ffebb..42188ae2 100644 --- a/efile_app/efile/static/js/filing-payload.js +++ b/efile_app/efile/static/js/filing-payload.js @@ -150,21 +150,20 @@ const FilingPayload = { * distinction: `users` is the list of filing parties, whoever they are. * * @param {Object} party a durable draft party row - * @param {Object} userData the signed-in filer's contact details + * @param {string} noticeEmail where notices about this case should go */ - filingPartyFromDraft(party, userData) { + filingPartyFromDraft(party, noticeEmail) { const base = this.partyFromDraft(party); return { ...base, mobile_number: base.phone_number, date_of_birth: "", is_form_filler: false, - // Tyler rejects a new case whose first filing party has no email. - // Someone filing for another person often does not have one for - // them, and the filer's own address is the honest stand-in: they - // are already this envelope's lead contact, and they are who the - // court would reach about this filing. - email: base.email || userData.email + // Tyler rejects a new case whose first filing party has no email, + // and someone filing for another person often does not have one + // for them. The notice address is the answer they gave to exactly + // that question, on the parties screen, and it is theirs to change. + email: base.email || noticeEmail }; }, @@ -191,11 +190,15 @@ const FilingPayload = { throw new Error('Party type could not be determined. This is required for eFiling.'); } + // Where the court should write about this case: the filer's own + // address, unless someone filing for another person said otherwise. + const noticeEmail = caseData.notice_email || userData.email; + // The filer's own entry, built only when they are a party themselves. const mainUser = filerIsFilingParty ? this.accountUser(userData, partyType) : null; const users = filingParties.length ? filingParties.map((party) => ( - party.role === "filer" ? mainUser : this.filingPartyFromDraft(party, userData) + party.role === "filer" ? mainUser : this.filingPartyFromDraft(party, noticeEmail) )) : [mainUser]; // Add second user if needed for name changes @@ -275,13 +278,16 @@ const FilingPayload = { ...(caseData?.amount_in_controversy ? { amount_in_controversy: caseData.amount_in_controversy } : {}), + // "Someone to contact about this case", in the EFSP's own words. + // The person filing, at whichever address they said notices about + // the case should reach. lead_contact: { name: { first: firstName, middle: middleName, last: lastName }, - email: userData.email + email: noticeEmail }, return_date: "" }; diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index 48034e7a..6c211a8a 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -12,6 +12,24 @@

{% translate "Who is involved in this case?" %}

{% translate "Start with your own part in this case. Then we will ask about any other people, one at a time." %}

+ {% if named_in_document %} +
+ {% csrf_token %} + + + + + {% if named_in_document_role %} + {% blocktranslate with name=named_in_document_name role=named_in_document_role %}Your document lists {{ name }} as the {{ role }} — that is your name too. Is this you?{% endblocktranslate %} + {% else %} + {% blocktranslate with name=named_in_document_name %}Your document lists {{ name }}, which is your name too. Is this you?{% endblocktranslate %} + {% endif %} + + + {% translate "If it is a different person with the same name, leave them on the list and answer for yourself below." %} + +
+ {% endif %}
{% csrf_token %} @@ -96,6 +114,17 @@

{% translate "Who is involved in this case?" %}

{% endfor %}
+ {% if not party_types %}

diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index f7b79f06..ae60353f 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -128,6 +128,13 @@

{% translate "Your information" %}

{% endif %} {% endif %} + {% if notice_email %} +

+ {% blocktranslate with email=notice_email %}Notices about this case go to {{ email }}.{% endblocktranslate %} + {% translate "Edit" %} +

+ {% endif %}
diff --git a/efile_app/efile/tests/test_extracted_parties.py b/efile_app/efile/tests/test_extracted_parties.py index 5971a678..7561f7a1 100644 --- a/efile_app/efile/tests/test_extracted_parties.py +++ b/efile_app/efile/tests/test_extracted_parties.py @@ -9,8 +9,19 @@ from efile.models import FilingDocument, FilingDraft, FilingParty from efile.party_sides import PartySide from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY -from efile.services.extracted_parties import extracted_party_suggestions, looks_like_organization, split_person_name -from efile.services.people import absorb_filer_duplicates, apply_party_sides, guess_filer_party_type, match_party_type +from efile.services.extracted_parties import ( + extracted_party_suggestions, + looks_like_organization, + save_reviewed_parties, + split_person_name, +) +from efile.services.people import ( + absorb_filer_duplicates, + apply_party_sides, + filer_name_match, + guess_filer_party_type, + match_party_type, +) from efile.workflow import ExistingCase, WorkflowStepKey PARTY_TYPES = [ @@ -299,7 +310,13 @@ def test_a_party_type_the_filer_already_chose_is_never_overwritten(review_draft) @pytest.mark.django_db def test_the_filer_is_not_also_filed_as_a_second_person_of_the_same_name(review_draft): filer = FilingParty.objects.create( - draft=review_draft, role="filer", sort_order=0, first_name="Alex", last_name="Rivera" + draft=review_draft, + role="filer", + sort_order=0, + first_name="Alex", + last_name="Rivera", + party_type="plaintiff", + party_type_name="Plaintiff/Petitioner", ) FilingParty.objects.create( draft=review_draft, @@ -326,6 +343,29 @@ def test_the_filer_is_not_also_filed_as_a_second_person_of_the_same_name(review_ assert [party.last_name for party in FilingParty.objects.filter(draft=review_draft, role="other")] == ["Lee"] +@pytest.mark.django_db +def test_a_caption_name_matching_a_filer_who_is_not_a_party_is_kept(review_draft): + """Two people share a name, and someone filing for a relative they are + named after is the whole reason that answer exists. Deleting the party on + a name alone would take a real party out of the case.""" + + FilingParty.objects.create(draft=review_draft, role="filer", sort_order=0, first_name="Alex", last_name="Rivera") + named = FilingParty.objects.create( + draft=review_draft, + role="other", + sort_order=0, + first_name="Alex", + last_name="Rivera", + party_side=PartySide.INITIATING, + ) + + side = absorb_filer_duplicates(review_draft) + + assert side == PartySide.INITIATING + assert FilingParty.objects.filter(pk=named.pk).exists() + assert filer_name_match(review_draft) == named + + @pytest.mark.django_db def test_the_documents_own_answer_beats_the_case_posture_guess(review_draft): """A new case is usually opened by the plaintiff, but not this one: the @@ -389,3 +429,65 @@ def test_the_party_screen_maps_sides_and_asks_only_for_what_is_missing(client, r assert FilingParty.objects.filter(draft=review_draft, role="other").count() == 1 assert response.status_code == 302 assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + + +# --- Answers that are not names ---------------------------------------------- + + +@pytest.mark.parametrize( + "answer", + ["Unknown", "unknown party", "N/A", "None", "Not listed", "TBD", "et al.", "See attached", "Defendants"], +) +def test_a_model_saying_it_found_nobody_does_not_become_a_party(answer): + """The names field is where a model puts "Unknown" when the caption had + nobody in it, and the filer should not have to work out that Unknown is + not a person.""" + + assert extracted_party_suggestions({"defendant or respondent names": answer}) == [] + + +@pytest.mark.parametrize( + "name", + ["All Unknown Occupants", "Unknown Occupants of 12 Main Street", "Jane None", "Party City Inc"], +) +def test_a_real_name_containing_one_of_those_words_is_kept(name): + """ "All Unknown Occupants" is a real defendant in a real eviction. The + guard compares whole names for exactly this reason.""" + + assert [item["name"] for item in extracted_party_suggestions({"defendant or respondent names": name})] == [name] + + +def test_a_placeholder_among_real_names_is_the_only_one_dropped(): + suggestions = extracted_party_suggestions({"defendant or respondent names": "Morgan Lee; Unknown; Sam Lee"}) + + assert [item["name"] for item in suggestions] == ["Morgan Lee", "Sam Lee"] + + +def test_the_same_person_read_onto_two_sides_is_listed_once(): + """A name in both the caption and the "other parties" answer is one person + misread, not two people, and adding them twice adds a party to the case.""" + + suggestions = extracted_party_suggestions( + { + "plaintiff or petitioner names": "Alex Rivera", + "defendant or respondent names": "Morgan Lee", + "other party names": "Morgan Lee (Tenant)", + } + ) + + assert [item["name"] for item in suggestions] == ["Alex Rivera", "Morgan Lee"] + assert [item["side"] for item in suggestions] == [PartySide.INITIATING, PartySide.RESPONDING] + + +@pytest.mark.django_db +def test_a_placeholder_typed_into_the_review_screen_adds_nobody(review_draft): + save_reviewed_parties( + review_draft, + [ + {"id": None, "name": "Morgan Lee", "side": PartySide.RESPONDING, "role_hint": ""}, + {"id": None, "name": "Unknown", "side": PartySide.RESPONDING, "role_hint": ""}, + ], + ) + + names = [party.last_name for party in FilingParty.objects.filter(draft=review_draft, role="other")] + assert names == ["Lee"] diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py index 943a3e63..b3726dba 100644 --- a/efile_app/efile/tests/test_filing_on_behalf.py +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -18,13 +18,16 @@ from efile.models import FilingDocument, FilingDraft, FilingParty from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY -from efile.services.people import NOT_A_PARTY, filing_parties, party_is_complete +from efile.services.drafts import read_case_data +from efile.services.people import NOT_A_PARTY, absorb_filer_duplicates, filing_parties, party_is_complete from efile.workflow import ExistingCase, WorkflowStepKey PARTIES_URL = reverse("parties", kwargs={"jurisdiction": "illinois"}) PAYMENT_URL = reverse("payment", kwargs={"jurisdiction": "illinois"}) REVIEW_URL = reverse("case_review", kwargs={"jurisdiction": "illinois"}) +NOTICE_EMAIL = "helper@example.com" + PARTY_TYPES = [ {"code": "plaintiff", "name": "Plaintiff", "required": True}, {"code": "defendant", "name": "Defendant", "required": True}, @@ -123,7 +126,7 @@ def test_a_filer_who_is_not_a_party_names_who_they_are_filing_for(client, draft) filer = make_filer(draft) tenant, landlord = both_sides(draft) - response = post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + response = post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email=NOTICE_EMAIL) filer.refresh_from_db() tenant.refresh_from_db() @@ -186,7 +189,7 @@ def test_someone_can_be_filed_for_by_two_co_parties_at_once(client, draft): filer = make_filer(draft) tenant, landlord = both_sides(draft) - post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=[tenant.pk, landlord.pk]) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=[tenant.pk, landlord.pk], notice_email=NOTICE_EMAIL) assert {party.pk for party in filing_parties(draft)} == {tenant.pk, landlord.pk} filer.refresh_from_db() @@ -202,7 +205,7 @@ def test_becoming_a_party_takes_the_filing_back_from_who_you_named(client, draft filer = make_filer(draft) tenant, _landlord = both_sides(draft) - post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email=NOTICE_EMAIL) post_parties(client, filer_party_type="defendant") @@ -219,7 +222,7 @@ def test_filing_for_someone_else_gives_up_your_own_party_type(client, draft): filer = make_filer(draft, party_type="defendant", party_type_name="Defendant", is_filing_party=True) tenant, _landlord = both_sides(draft) - post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email=NOTICE_EMAIL) filer.refresh_from_db() assert filer.party_type == "" @@ -349,12 +352,138 @@ def test_review_says_who_the_filing_is_for_and_offers_to_add_you(client, draft): @pytest.mark.django_db -def test_filing_for_someone_who_turns_out_to_be_you_still_files_for_them(client, draft): - """The roster's copy of the filer is folded into the filer's own row. The - envelope has to come out on behalf of someone all the same.""" +def test_folding_a_party_who_is_you_keeps_the_filing_on_behalf_of_someone(draft): + """The roster's copy of the filer is folded into the filer's own row once + they say they are a party. The envelope has to come out on behalf of + somebody all the same.""" - filer = make_filer(draft, first_name="Jamie", last_name="Rivera") + filer = make_filer(draft, first_name="Jamie", last_name="Rivera", party_type="defendant") twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera", is_filing_party=True) + + absorb_filer_duplicates(draft) + + filer.refresh_from_db() + assert not FilingParty.objects.filter(pk=twin.pk).exists() + assert filing_parties(draft) == [filer] + + +# --- Drafts from before the question existed --------------------------------- + + +@pytest.mark.django_db +def test_a_draft_answered_before_this_question_still_files_as_the_filer(client, draft): + """Back then a filer with a party type was the only possible filing party, + so that is what their unmarked draft still means.""" + + filer = make_filer(draft, party_type="defendant", party_type_name="Defendant") + + assert filer.is_filing_party is False + assert filing_parties(draft) == [filer] + + +# --- Where notices about the case go ----------------------------------------- + + +@pytest.mark.django_db +def test_the_notice_address_is_offered_filled_in_with_the_filers_own(client, draft): + make_filer(draft, email="helper@example.com") + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert "Where should notices about this case go?" in content + assert re.search(r'name="notice_email"[^>]*value="helper@example\.com"', content) is not None + + +@pytest.mark.django_db +def test_a_notice_address_can_be_someone_other_than_the_filer(client, draft): + """The point of asking: the court may need to write to the party, or to + whoever handles their mail, rather than to the person filing.""" + + make_filer(draft) + tenant, _landlord = both_sides(draft) + + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email="tenant@example.com") + + draft.refresh_from_db() + assert draft.notice_email == "tenant@example.com" + + +@pytest.mark.django_db +def test_filing_for_someone_else_without_any_notice_address_is_refused(client, draft): + make_filer(draft) + tenant, _landlord = both_sides(draft) + + response = post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email="") + + tenant.refresh_from_db() + assert response.status_code == 200 + assert "Give an email address for notices" in response.content.decode() + assert tenant.is_filing_party is False + + +@pytest.mark.django_db +def test_a_notice_address_that_is_not_an_address_is_refused(client, draft): + make_filer(draft) + tenant, _landlord = both_sides(draft) + + response = post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email="not an email") + + assert response.status_code == 200 + assert "Give an email address for notices" in response.content.decode() + + +@pytest.mark.django_db +def test_becoming_a_party_stops_naming_a_separate_notice_address(client, draft): + """It applied to a filing on someone else's behalf. There isn't one now.""" + + make_filer(draft) + tenant, _landlord = both_sides(draft) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=tenant.pk, notice_email="tenant@example.com") + + post_parties(client, filer_party_type="defendant") + + draft.refresh_from_db() + assert draft.notice_email == "" + + +@pytest.mark.django_db +def test_review_shows_where_notices_go_with_a_way_to_change_it(client, draft): + make_filer(draft) + tenant, _landlord = both_sides(draft) + tenant.is_filing_party = True + tenant.save(update_fields=["is_filing_party"]) + draft.notice_email = "tenant@example.com" + draft.selected_payment_account_id = "pay-1" + draft.selected_payment_account_name = "Card" + draft.save(update_fields=["notice_email", "selected_payment_account_id", "selected_payment_account_name"]) + + content = client.get(REVIEW_URL).content.decode() + + assert "Notices about this case go to tenant@example.com" in content + assert "#notice_email" in content + + +@pytest.mark.django_db +def test_the_notice_address_reaches_the_filing_payload(client, draft): + make_filer(draft) + tenant, _landlord = both_sides(draft) + tenant.is_filing_party = True + tenant.save(update_fields=["is_filing_party"]) + draft.notice_email = "tenant@example.com" + draft.save(update_fields=["notice_email"]) + + assert read_case_data(draft)["notice_email"] == "tenant@example.com" + + +# --- The document naming the filer ------------------------------------------- + + +@pytest.mark.django_db +def test_a_caption_party_with_the_filers_name_is_offered_as_them(client, draft): + make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera") make_party( draft, 1, @@ -366,22 +495,98 @@ def test_filing_for_someone_who_turns_out_to_be_you_still_files_for_them(client, ) with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): - client.get(PARTIES_URL) + content = client.get(PARTIES_URL).content.decode() + + assert "Jamie Rivera" in content + assert "Is this you?" in content + assert f'name="party_id" value="{twin.pk}"' in content + # The concrete suggestion replaces the vaguer one rather than joining it. + assert 'id="apply-party-type-guess"' not in content + + +@pytest.mark.django_db +def test_confirming_that_party_is_you_adopts_their_role_and_stops_listing_them(client, draft): + filer = make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera") + + response = post_parties(client, action="claim_party", party_id=twin.pk) filer.refresh_from_db() + assert response.status_code == 302 + assert response.url.endswith("#your-role") assert not FilingParty.objects.filter(pk=twin.pk).exists() + assert filer.party_type == "defendant" + assert filer.is_filing_party is True assert filing_parties(draft) == [filer] -# --- Drafts from before the question existed --------------------------------- +@pytest.mark.django_db +def test_a_party_who_shares_the_filers_name_is_left_alone_until_they_say_so(client, draft): + """Saying nothing is not saying yes. Two people share a name, and someone + filing for a relative they are named after is why this screen exists.""" + + make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera") + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + client.get(PARTIES_URL) + + assert FilingParty.objects.filter(pk=twin.pk).exists() @pytest.mark.django_db -def test_a_draft_answered_before_this_question_still_files_as_the_filer(client, draft): - """Back then a filer with a party type was the only possible filing party, - so that is what their unmarked draft still means.""" +def test_the_suggestion_stops_once_the_filer_has_answered_for_themselves(client, draft): + """A filer who said they are filing for someone else has answered the + question, and should not be asked it again every time they come back.""" - filer = make_filer(draft, party_type="defendant", party_type_name="Defendant") + make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera") + make_party( + draft, + 1, + party_type="plaintiff", + party_type_name="Plaintiff", + first_name="", + last_name="", + organization_name="Landlord LLC", + ) + post_parties(client, filer_party_type=NOT_A_PARTY, filing_for=twin.pk, notice_email=NOTICE_EMAIL) - assert filer.is_filing_party is False - assert filing_parties(draft) == [filer] + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert "Is this you?" not in content + + +# --- Parties nobody added ---------------------------------------------------- + + +@pytest.mark.django_db +def test_a_party_started_and_never_named_does_not_stay_on_the_list(client, draft): + """Adding a person makes the row before the form that names them, so + leaving without saving used to strand a nameless entry on the list.""" + + make_filer(draft) + both_sides(draft) + abandoned = FilingParty.objects.create(draft=draft, role="other", sort_order=9) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + client.get(PARTIES_URL) + + assert not FilingParty.objects.filter(pk=abandoned.pk).exists() + + +@pytest.mark.django_db +def test_the_courts_own_required_party_placeholder_is_not_swept_up(client, draft): + """It is nameless for a different reason: the court requires that party, + and the filer is on their way to naming them.""" + + make_filer(draft) + placeholder = FilingParty.objects.create( + draft=draft, role="other", sort_order=9, party_type="plaintiff", party_type_name="Plaintiff" + ) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + client.get(PARTIES_URL) + + assert FilingParty.objects.filter(pk=placeholder.pk).exists() diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 20866a88..771c7fb1 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -1,17 +1,24 @@ from django.contrib import messages +from django.core.exceptions import ValidationError +from django.core.validators import validate_email from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.http import require_http_methods from efile.api.suffolk_api_views import get_tyler_token from efile.models import FilingParty +from efile.party_sides import PARTY_SIDE_LABELS from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot +from efile.services.extracted_parties import party_display_name from efile.services.people import ( NOT_A_PARTY, absorb_filer_duplicates, apply_party_sides, + claim_party_as_filer, + discard_empty_parties, ensure_required_parties, + filer_name_match, filing_party_candidates, get_case_questions, get_party_types, @@ -33,6 +40,14 @@ def _parties_url(jurisdiction, return_to=None): return with_return_to(reverse("parties", kwargs={"jurisdiction": jurisdiction}), return_to) +def _is_email(value): + try: + validate_email(value) + except ValidationError: + return False + return True + + def _chosen_filing_parties(request, draft): """The roster rows the filer ticked as the people they are filing for.""" @@ -81,6 +96,12 @@ def parties(request, jurisdiction): return redirect("your_information", jurisdiction=jurisdiction) party_types = get_party_types(draft) party_type_names = {item["code"]: item["name"] for item in party_types} + # A person the filer started adding and never named is not a party they + # meant to add, and reaches this list as an entry they cannot tell apart + # from one they did. Only ever cleared on the way in: a POST is somebody + # acting on a row, including the blank one they just made. + if request.method == "GET": + discard_empty_parties(draft) # The document said which side each person is on; the case type -- settled # by now -- says what this court calls that side. Folding the filer's own # duplicate in first keeps them from reaching the court twice. @@ -105,6 +126,12 @@ def parties(request, jurisdiction): draft.current_step = WorkflowStepKey.PARTY_DETAILS draft.save(update_fields=["current_step", "updated_at"]) return redirect(_party_details_url(jurisdiction, party, return_to)) + if action == "claim_party": + # "Yes, that party in my document is me." + party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") + claim_party_as_filer(draft, party) + messages.success(request, "Added you to the case as this party. Check the role below.") + return redirect(f"{_parties_url(jurisdiction, return_to)}#your-role") if action == "remove": party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") party.delete() @@ -122,19 +149,30 @@ def parties(request, jurisdiction): # Filing for someone else. Tyler still needs a party to file on # behalf of, so the filer names one instead of becoming one. chosen = _chosen_filing_parties(request, draft) + notice_email = request.POST.get("notice_email", "").strip() if not chosen: messages.error(request, "Choose who you are filing for.") + elif not _is_email(notice_email): + messages.error(request, "Give an email address for notices about this case.") else: filer.party_type = "" filer.party_type_name = "" filer.save(update_fields=["party_type", "party_type_name", "updated_at"]) set_filing_parties(draft, chosen) + draft.notice_email = notice_email + draft.save(update_fields=["notice_email", "updated_at"]) return _continue_from_parties(request, jurisdiction, draft, party_types, return_to) elif filer_type in party_type_names: filer.party_type = filer_type filer.party_type_name = party_type_names.get(filer_type, filer.party_type_name) filer.save(update_fields=["party_type", "party_type_name", "updated_at"]) set_filing_parties(draft, [filer]) + if draft.notice_email: + # A party in their own case is reached at their own address, + # and the review screen should stop naming one that no longer + # applies to anything. + draft.notice_email = "" + draft.save(update_fields=["notice_email", "updated_at"]) return _continue_from_parties(request, jurisdiction, draft, party_types, return_to) else: messages.error(request, "Choose your role in this case, or tell us you are filing for someone else.") @@ -143,15 +181,24 @@ def parties(request, jurisdiction): {"party": party, "complete": party_is_complete(party, party_types=party_types)} for party in FilingParty.objects.filter(draft=draft) ] - guessed_party_type = None if filer.party_type else guess_filer_party_type(draft, party_types) + saved_filing_for = { + party.pk for party in FilingParty.objects.filter(draft=draft, role="other", is_filing_party=True) + } + # The document naming the filer is a better answer than the case posture, + # and a more concrete question to put to them: it can say which party they + # are rather than which side they are probably on. Only asked while the + # role question is still unanswered -- a filer who has said they are + # filing for someone else has answered it, and does not need telling again + # every time they come back to this screen. + named_in_document = None if saved_filing_for else filer_name_match(draft) + guessed_party_type = ( + None if filer.party_type or named_in_document is not None else guess_filer_party_type(draft, party_types) + ) # Which branch of the role question the screen comes back on. A filer who # has never answered gets neither pre-selected -- their own role is not # something to guess at on their behalf -- but an answer that has just # been refused is still their answer, and stays on the screen with the # error rather than making them find it again. - saved_filing_for = { - party.pk for party in FilingParty.objects.filter(draft=draft, role="other", is_filing_party=True) - } attempted = request.POST.get("filer_party_type", "").strip() if request.method == "POST" else "" attempted_filing_for = {int(value) for value in request.POST.getlist("filing_for") if str(value).isdigit()} filing_for = attempted_filing_for or saved_filing_for @@ -164,10 +211,24 @@ def parties(request, jurisdiction): "roster": roster, "guessed_party_type": guessed_party_type, "not_a_party_value": NOT_A_PARTY, + "named_in_document": named_in_document, + "named_in_document_name": party_display_name(named_in_document) if named_in_document else "", + "named_in_document_role": ( + (named_in_document.party_type_name or PARTY_SIDE_LABELS.get(named_in_document.party_side, "")) + if named_in_document + else "" + ), "filing_for_someone_else": attempted == NOT_A_PARTY or (bool(saved_filing_for) and not filer.party_type), "filing_for_candidates": [ {"party": party, "selected": party.pk in filing_for} for party in filing_party_candidates(draft) ], + # Offered filled in with the filer's own address, because that is the + # right answer most of the time and a blank box is a question nobody + # asked to be asked. It stays editable for the times it is not. + "notice_email": ( + request.POST.get("notice_email", "").strip() if request.method == "POST" else draft.notice_email + ) + or filer.email, } context.update(get_workflow_context(WorkflowStepKey.PARTIES, jurisdiction, draft)) return render(request, "efile/parties.html", context) diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index b1c36562..623bb38b 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -52,6 +52,10 @@ def case_review(request, jurisdiction): party_display_name(party) for party in parties.filter(is_filing_party=True).exclude(role="filer").order_by("sort_order", "created_at") ], + # Only shown when it was actually asked for. A filer who is a party in + # their own case is reached at their account address, and saying so + # here would be one more line of screen for nothing. + "notice_email": draft.notice_email, "documents": FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at"), "question_answers": question_answers, # Everything in one envelope reaches the clerk together. This is the diff --git a/efile_app/js-tests/filing-payload.test.js b/efile_app/js-tests/filing-payload.test.js index 373708cc..edd13932 100644 --- a/efile_app/js-tests/filing-payload.test.js +++ b/efile_app/js-tests/filing-payload.test.js @@ -495,4 +495,36 @@ test("co-parties who are both filing are both named as filing parties", () => { result.al_court_bundle[0].filing_parties, ["users[0]", "users[1]"] ); +}); +test("the notice address answers both the lead contact and a party with no email", () => { + const handler = makeHandler(); + const caseData = structuredClone(FILING_FOR_SOMEONE_ELSE); + caseData.notice_email = "aunt@example.com"; + caseData.filing_parties[1].email = ""; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual(result.users[0].email, "aunt@example.com"); + assert.strictEqual(result.lead_contact.email, "aunt@example.com"); +}); + +test("a party's own email is not overwritten by the notice address", () => { + const handler = makeHandler(); + const caseData = structuredClone(FILING_FOR_SOMEONE_ELSE); + caseData.notice_email = "aunt@example.com"; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual(result.users[0].email, "tenant@example.com"); +}); + +test("with no notice address given, the filer's own is still what is used", () => { + const handler = makeHandler(); + const caseData = structuredClone(FILING_FOR_SOMEONE_ELSE); + caseData.filing_parties[1].email = ""; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual(result.users[0].email, "helper@example.com"); + assert.strictEqual(result.lead_contact.email, "helper@example.com"); }); \ No newline at end of file From 84f8762222993833129f1575f19af73ae9c66d0a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 21:25:01 -0400 Subject: [PATCH 3/8] Claim a detected party as yourself, and tell Tyler which parties are companies Two things found filing a real Kane County small claim. **Organizations reached Tyler as people with no surname.** The EFSP reads a party as a business only when the entry says `person_type`, and LITEFile has never sent that field, so a company went over as a PersonType whose PersonSurName was empty -- `partyFromDraft` puts an organization's one name in `name.first` and leaves `name.last` blank. The court rejected the whole envelope as far along as the fee quote: "the court's filing service returned status 422: PersonSurName is required or does not match regular expression." Confirmed against the live Kane endpoint with the same case codes: without the field, 422 with that exact message; with it, 200 and a fee quote. This is older than the filing-party work, but that work made it much easier to hit -- an eviction or a small claim against a company now routinely puts the company on a roster the filer never has to open. **There was no way to say "that detected party is me".** The court's required party types are routinely all taken by people the document named, so the only offers on the parties screen were to add yourself as a *second* plaintiff or to say you were not a party at all. Neither is what someone means when their own name is already in the caption under a spelling the name match did not catch, or when they are the tenant the AI read off the complaint. Every listed party now carries "This is me", which adopts that party's court role, folds them into the filer's own row -- the one with the address the court needs -- and files on their behalf. The same button is on the party-details screen for a party that already has a name, where it used to be offered only for a blank row, and the party list says so for a filer who would otherwise add themselves twice. The name-match suggestion is now the pre-answered case of this rather than the only way in. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- efile_app/efile/services/people.py | 12 ++- efile_app/efile/static/js/filing-payload.js | 9 ++ efile_app/efile/templates/efile/parties.html | 17 ++++ .../efile/templates/efile/party_details.html | 16 ++-- .../efile/tests/test_filing_on_behalf.py | 84 ++++++++++++++++++- efile_app/efile/views/parties.py | 21 +++-- efile_app/efile/views/party_details.py | 3 + efile_app/js-tests/filing-payload.test.js | 79 +++++++++++++++++ 8 files changed, 220 insertions(+), 21 deletions(-) diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index 9c5be54d..10e3d7f7 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -132,11 +132,14 @@ def claim_party_as_filer(draft: FilingDraft, party: FilingParty) -> None: filer = FilingParty.objects.filter(draft=draft, role="filer").first() if filer is None: return - filer.party_type = party.party_type - filer.party_type_name = party.party_type_name + filer.party_type = party.party_type or filer.party_type + filer.party_type_name = party.party_type_name or filer.party_type_name filer.party_side = filer.party_side or party.party_side filer.party_role_hint = filer.party_role_hint or party.party_role_hint - filer.is_filing_party = True + # A row claimed before anyone gave it a court role leaves the role question + # unanswered rather than making the filer a filing party with no role at + # all, which is a state the payload cannot say anything useful about. + filer.is_filing_party = bool(filer.party_type) filer.save( update_fields=[ "party_type", @@ -148,7 +151,8 @@ def claim_party_as_filer(draft: FilingDraft, party: FilingParty) -> None: ] ) party.delete() - set_filing_parties(draft, [filer]) + if filer.is_filing_party: + set_filing_parties(draft, [filer]) def discard_empty_parties(draft: FilingDraft) -> int: diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index 42188ae2..39ec89b2 100644 --- a/efile_app/efile/static/js/filing-payload.js +++ b/efile_app/efile/static/js/filing-payload.js @@ -60,6 +60,15 @@ const FilingPayload = { }; return { party_type: party.party_type, + // An organization has one name where a person has three, and the + // EFSP only reads `name.first` that way when the entry says it is + // a business. Without this a company reaches Tyler as a person + // with no surname, and the court rejects the whole envelope -- + // "PersonSurName is required or does not match regular + // expression" -- as far along as the fee quote. + ...(party.organization_name ? { + person_type: "business" + } : {}), name: { first: party.first_name || party.organization_name || "", middle: party.middle_name || "", diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index 6c211a8a..3a7aaadb 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -147,6 +147,11 @@

{% translate "Party list" %}

{% translate "Add me as a party" %} {% endif %} + {% if not filer.party_type and roster|length > 1 %} +

+ {% translate "If one of the people below is you, choose \"This is me\" rather than adding yourself again." %} +

+ {% endif %} {% csrf_token %} @@ -189,6 +194,18 @@

{% translate "Party list" %}

{% endif %} {% if item.party.role == "other" %} + {% if not filer.party_type %} + {# The court's required parties are often all taken by people the + document named, so becoming one of them is claiming a row rather + than adding another person. #} + + {% csrf_token %} + + + + + + {% endif %} {% translate "Edit" %}
diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index e057afac..1eb16809 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -208,19 +208,21 @@

- {% if not party.first_name and not party.organization_name %} - {# Only offered on a party nobody has named yet: past that point this - button would throw away someone's typing. #} + {% if not filer_is_party %} + {# Offered for a party the document named as much as for a blank row: + a filer looking at a detected party is exactly who needs to say that + it is them, and their own name and address are already on the draft. #}
{% csrf_token %} - + - - - {% translate "We already have your name and address — you only need to choose your role." %} + + + {% translate "We will list you in the case as this party. We already have your name and address, so there is nothing more to fill in here." %} +
{% endif %}

diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py index b3726dba..f97c99ef 100644 --- a/efile_app/efile/tests/test_filing_on_behalf.py +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -290,7 +290,7 @@ def test_the_add_a_person_screen_offers_actually_this_is_me(client, draft): url = f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={blank.pk}" response = client.get(url) - assert "Actually, this party is me" in response.content.decode() + assert "This party is me" in response.content.decode() @pytest.mark.django_db @@ -301,7 +301,7 @@ def test_saying_a_new_party_is_you_drops_the_row_instead_of_duplicating_you(clie make_filer(draft) blank = FilingParty.objects.create(draft=draft, role="other", sort_order=0) - response = post_parties(client, action="remove", party_id=blank.pk, instead="me") + response = post_parties(client, action="claim_party", party_id=blank.pk) assert response.status_code == 302 assert response.url.endswith("#your-role") @@ -590,3 +590,83 @@ def test_the_courts_own_required_party_placeholder_is_not_swept_up(client, draft client.get(PARTIES_URL) assert FilingParty.objects.filter(pk=placeholder.pk).exists() + + +# --- Becoming one of the parties the document already named ------------------ + + +@pytest.mark.django_db +def test_every_listed_party_can_be_claimed_as_you(client, draft): + """The court's required parties are routinely all taken by people the + document named, so being one of them is claiming a row rather than adding + another person -- which would file a second plaintiff nobody wanted.""" + + make_filer(draft) + tenant, landlord = both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + claimable = set(re.findall(r'name="party_id" value="(\d+)"', content)) + assert claimable == {str(tenant.pk), str(landlord.pk)} + assert content.count(">This is me") == 2 + # And the list says so, for a filer who would otherwise add themselves again. + assert "If one of the people below is you" in content + + +@pytest.mark.django_db +def test_claiming_a_detected_party_takes_their_role_and_drops_the_row(client, draft): + filer = make_filer(draft) + tenant, landlord = both_sides(draft) + + post_parties(client, action="claim_party", party_id=tenant.pk) + + filer.refresh_from_db() + assert filer.party_type == "defendant" + assert filer.party_type_name == "Defendant" + assert filer.is_filing_party is True + assert not FilingParty.objects.filter(pk=tenant.pk).exists() + assert FilingParty.objects.filter(pk=landlord.pk).exists() + assert filing_parties(draft) == [filer] + + +@pytest.mark.django_db +def test_a_filer_who_is_already_a_party_is_not_offered_more_of_them(client, draft): + make_filer(draft, party_type="defendant", party_type_name="Defendant") + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert "This is me" not in content + + +@pytest.mark.django_db +def test_the_party_details_screen_offers_it_for_a_party_with_a_name(client, draft): + """A filer sent to check a detected party's details is exactly who needs + to be able to say that the party is them.""" + + make_filer(draft) + tenant, _landlord = both_sides(draft) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + url = f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={tenant.pk}" + content = client.get(url).content.decode() + + assert "This party is me" in content + + +@pytest.mark.django_db +def test_claiming_a_party_with_no_role_yet_leaves_the_role_question_to_answer(client, draft): + """Nothing to adopt: the filer should not come out of it as a filing party + with no court role, which the payload cannot describe.""" + + filer = make_filer(draft) + blank = FilingParty.objects.create(draft=draft, role="other", sort_order=4) + + post_parties(client, action="claim_party", party_id=blank.pk) + + filer.refresh_from_db() + assert filer.party_type == "" + assert filer.is_filing_party is False + assert not FilingParty.objects.filter(pk=blank.pk).exists() diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 771c7fb1..1059f3f9 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -127,20 +127,25 @@ def parties(request, jurisdiction): draft.save(update_fields=["current_step", "updated_at"]) return redirect(_party_details_url(jurisdiction, party, return_to)) if action == "claim_party": - # "Yes, that party in my document is me." + # "That party is me." Said of a person the document named, or of + # the blank row someone started before realising they were adding + # themselves. Either way the filer is already on this draft with a + # name and an address, so the other row goes rather than reaching + # the court as a second person. party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") claim_party_as_filer(draft, party) - messages.success(request, "Added you to the case as this party. Check the role below.") + filer.refresh_from_db() + if filer.party_type: + messages.success( + request, + f"You are listed in this case as the {filer.party_type_name or filer.party_type}.", + ) + else: + messages.success(request, "Choose your own role below to add yourself as a party.") return redirect(f"{_parties_url(jurisdiction, return_to)}#your-role") if action == "remove": party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") party.delete() - if request.POST.get("instead") == "me": - # "Actually, this is me" on the add-a-person screen. The blank - # row goes, because the filer is already on this draft once and - # a second copy of them would reach the court as two people. - messages.success(request, "Choose your own role below to add yourself as a party.") - return redirect(f"{_parties_url(jurisdiction, return_to)}#your-role") messages.success(request, "Party removed.") return redirect(_parties_url(jurisdiction, return_to)) diff --git a/efile_app/efile/views/party_details.py b/efile_app/efile/views/party_details.py index 447616b5..c8eaa6ef 100644 --- a/efile_app/efile/views/party_details.py +++ b/efile_app/efile/views/party_details.py @@ -10,6 +10,7 @@ from efile.services.drafts import draft_snapshot from efile.services.party_requirements import address_is_blank, party_address_requirement from efile.services.people import ( + filer_is_party, get_case_questions, get_party_types, incomplete_parties, @@ -114,6 +115,8 @@ def party_details(request, jurisdiction): "is_logged_in": True, "filing_draft": draft_snapshot(draft), "party": party, + # Someone already listed in the case has no use for "this party is me". + "filer_is_party": filer_is_party(draft), "party_types": party_types, "party_kind": "organization" if party.organization_name else "person", "return_to": request.GET.get("return_to", ""), diff --git a/efile_app/js-tests/filing-payload.test.js b/efile_app/js-tests/filing-payload.test.js index edd13932..870a84c0 100644 --- a/efile_app/js-tests/filing-payload.test.js +++ b/efile_app/js-tests/filing-payload.test.js @@ -527,4 +527,83 @@ test("with no notice address given, the filer's own is still what is used", () = assert.strictEqual(result.users[0].email, "helper@example.com"); assert.strictEqual(result.lead_contact.email, "helper@example.com"); +}); +// -- Organizations ---------------------------------------------------------- + +test("an organization says it is a business, so it is not read as a nameless person", () => { + const handler = makeHandler(); + const caseData = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: true, + party_type: "PLA", + first_name: "Quinten", + last_name: "Steenhuis", + email: "q@example.com" + }, { + role: "other", + party_type: "DEF", + organization_name: "Fox River Phone Repair LLC" + }] + }; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + // Without this the court rejects the envelope with "PersonSurName is + // required", because an organization has no surname to give. + assert.strictEqual(result.other_parties[0].person_type, "business"); + assert.strictEqual(result.other_parties[0].name.first, "Fox River Phone Repair LLC"); +}); + +test("a person is not labelled a business", () => { + const handler = makeHandler(); + const caseData = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: true, + party_type: "PLA", + first_name: "Quinten", + last_name: "Steenhuis" + }, { + role: "other", + party_type: "DEF", + first_name: "Luca", + last_name: "Martin" + }] + }; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual("person_type" in result.other_parties[0], false); + assert.strictEqual(result.other_parties[0].name.last, "Martin"); +}); + +test("an organization being filed for is labelled too", () => { + const handler = makeHandler(); + const caseData = { + case_category: "cat", + case_type: "type", + filing_parties: [{ + role: "filer", + is_filing_party: false, + party_type: "", + first_name: "Quinten", + last_name: "Steenhuis", + email: "q@example.com" + }, { + role: "other", + is_filing_party: true, + party_type: "PLA", + organization_name: "Riverbend Properties LLC" + }] + }; + const userData = handler.userDataFromCaseData(caseData); + const result = handler.buildEFilingData(userData, caseData, {}, "pay-1"); + + assert.strictEqual(result.users[0].person_type, "business"); + assert.strictEqual(result.users[0].name.first, "Riverbend Properties LLC"); }); \ No newline at end of file From 90877d4289a64270b2a4f032361f2e5b5603b02c Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 22:05:17 -0400 Subject: [PATCH 4/8] Ask before replacing a party with the person filing "This is me" quietly took the claimed party's court role and deleted their row, which meant the name in the case silently became the name on the account. Two different people can be behind that click: someone whose caption name is not the one their account carries, and someone who read "this is me" as "I am helping this person" -- and the second is the reading the words invite. Only the first is what the button does, and it does it to the party list the court sees. So the two cases are no longer offered under the same words. A row that already carries the filer's name still says "This is me", and claiming it asks nothing, because nothing changes. A row with someone else's name says "Replace with me" and opens a confirmation that has to settle two things before anything happens: * Which name the court should see -- theirs, or the one already in the case. Neither is pre-selected: a filer whose complaint names them by a former name wants one, a filer correcting a misread name wants the other, and only they know which. The server refuses the claim without an answer, so a browser that never ran the dialog cannot skip the question either. * Whether they are *helping* that person rather than being them, with a button that backs out into the answer they actually wanted -- keeping the party on the filing and marking themselves as filing on that party's behalf. Claiming a row nobody has named yet is neither of those and still asks nothing: there is no name to replace. Fixes three things found by looking at the rendered page: multi-line {# #} comments were rendering as visible text (Django only treats those as comments on one line), the party list's hint was inside the heading's flex row and overlapped the buttons, and the row grid had one fewer column than the row now has controls, wrapping the delete button onto its own line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- efile_app/efile/services/people.py | 49 +++++- .../efile/static/css/reorganized-flow.css | 37 +++- efile_app/efile/static/js/claim-party.js | 158 ++++++++++++++++++ .../efile/components/claim_party_dialog.html | 57 +++++++ efile_app/efile/templates/efile/parties.html | 37 +++- .../efile/templates/efile/party_details.html | 26 ++- .../efile/tests/test_filing_on_behalf.py | 111 +++++++++++- efile_app/efile/views/parties.py | 25 ++- efile_app/efile/views/party_details.py | 9 + 9 files changed, 484 insertions(+), 25 deletions(-) create mode 100644 efile_app/efile/static/js/claim-party.js create mode 100644 efile_app/efile/templates/efile/components/claim_party_dialog.html diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index 10e3d7f7..fc52c60d 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -121,17 +121,55 @@ def filer_name_match(draft: FilingDraft) -> FilingParty | None: return matches[0] if matches else None -def claim_party_as_filer(draft: FilingDraft, party: FilingParty) -> None: - """Answer "yes, that party is me": become them, and stop listing them twice. +def names_match(filer: FilingParty | None, party: FilingParty | None) -> bool: + """Whether two rows are the same name once spelling is set aside.""" - The filer's own row is kept rather than the caption's, because it is the - one with the address and email the court needs, and the caption row is - deleted rather than left as a second person of the same name. + if filer is None or party is None: + return False + filer_name = _comparable(party_display_name(filer)) + return bool(filer_name) and filer_name == _comparable(party_display_name(party)) + + +def claim_replaces_a_name(filer: FilingParty | None, party: FilingParty | None) -> bool: + """Whether claiming this party would put a different name in the case. + + Claiming the blank row someone started is not replacing anybody, and + claiming a row that already carries the filer's name is confirming who + they are. Only the third case -- a named party who is not them by name -- + changes what the court is told the case is about, and only that one has a + question to ask first. + """ + + if party is None: + return False + return bool(party_display_name(party)) and not names_match(filer, party) + + +def claim_party_as_filer(draft: FilingDraft, party: FilingParty, *, use_party_name: bool = False) -> None: + """Answer "that party is me": become them, and stop listing them twice. + + The filer's own row is the one kept, because it is the one with the + address and email the court needs; the claimed row is deleted rather than + left behind as a second person in the case. + + Which name the court then sees is a real question whenever the two rows + are not the same name, and it is the caller's to have asked. Passing + ``use_party_name`` keeps what the case already says -- the right answer + for a filer whose caption name is not the one on their account -- and + the default keeps their own. """ filer = FilingParty.objects.filter(draft=draft, role="filer").first() if filer is None: return + name_fields: list[str] = [] + if use_party_name and party_display_name(party): + filer.first_name = party.first_name + filer.middle_name = party.middle_name + filer.last_name = party.last_name + filer.suffix = party.suffix + filer.organization_name = party.organization_name + name_fields = ["first_name", "middle_name", "last_name", "suffix", "organization_name"] filer.party_type = party.party_type or filer.party_type filer.party_type_name = party.party_type_name or filer.party_type_name filer.party_side = filer.party_side or party.party_side @@ -142,6 +180,7 @@ def claim_party_as_filer(draft: FilingDraft, party: FilingParty) -> None: filer.is_filing_party = bool(filer.party_type) filer.save( update_fields=[ + *name_fields, "party_type", "party_type_name", "party_side", diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index b2f3ba36..10e10cf4 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -1361,11 +1361,46 @@ margin: 0; } +.party-roster__hint { + color: var(--text-muted, #5b6470); + font-size: 0.9rem; + margin: 0.5rem 0 0; +} + +/* "This party is me" asks before it replaces somebody, so the question has to + read as a question rather than as more of the page behind it. */ +.claim-party-dialog { + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: 0 18px 48px rgba(15, 32, 60, 0.28); + max-width: 34rem; + padding: 1.5rem; + width: calc(100% - 2rem); +} + +.claim-party-dialog::backdrop { + background: rgba(15, 32, 60, 0.45); +} + +.claim-party-dialog h2 { + margin-top: 0; +} + +.claim-party-dialog .workflow-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; +} + .party-row { + align-items: center; border: 1px solid var(--border-default); border-radius: 10px; display: grid; - grid-template-columns: auto 1fr auto auto auto; + /* Avatar, name, status, then however many of "this is me" / edit / remove + this row offers -- a column that has nothing in it takes no width. */ + grid-template-columns: auto 1fr repeat(4, auto); margin-top: 0.75rem; padding: 0.85rem 1rem; } diff --git a/efile_app/efile/static/js/claim-party.js b/efile_app/efile/static/js/claim-party.js new file mode 100644 index 00000000..1093bd30 --- /dev/null +++ b/efile_app/efile/static/js/claim-party.js @@ -0,0 +1,158 @@ +/** + * "This party is me" -- confirming it, before it happens. + * + * Claiming a party replaces someone already in the case with the person + * signed in, so the button opens a question rather than doing it. Two things + * the question has to settle: + * + * * whose name the court sees, when the row and the account are not the same + * name -- the answer is required, because either one can be right and only + * the filer knows which; + * * that this is not where someone says they are *helping* that party, which + * is the reading the words invite and the wrong one for anybody who is not + * that person. + * + * Every claim form posts through here, so a browser with no dialog support + * still gets asked, and the server refuses an unanswered name question in + * any case. + */ +(function() { + const dialog = document.getElementById("claim-party-dialog"); + const forms = Array.from(document.querySelectorAll("form.claim-party-form")); + if (!dialog || !forms.length) return; + + const title = document.getElementById("claim-party-title"); + const lede = document.getElementById("claim-party-lede"); + const helping = document.getElementById("claim-party-helping"); + const helpingDetail = document.getElementById("claim-party-helping-detail"); + const nameChoice = document.getElementById("claim-party-name-choice"); + const nameMine = document.getElementById("claim-party-name-mine"); + const nameTheirs = document.getElementById("claim-party-name-theirs"); + const nameError = document.getElementById("claim-party-name-error"); + const confirmButton = document.getElementById("claim-party-confirm"); + const cancelButton = document.getElementById("claim-party-cancel"); + const insteadButton = document.getElementById("claim-party-instead-filing-for"); + + let activeForm = null; + + function choiceInputs() { + return Array.from(dialog.querySelectorAll('input[name="claim_party_name_choice"]')); + } + + function open(form) { + activeForm = form; + const partyName = form.dataset.partyName || gettext("this party"); + const role = form.dataset.partyRole || ""; + const filerName = form.dataset.filerName || ""; + const replacesAName = form.dataset.replacesAName === "true"; + + title.textContent = interpolate(gettext("Replace %(name)s with you?"), { + name: partyName + }, true); + lede.textContent = role ? + interpolate( + gettext("This filing lists %(name)s as the %(role)s. We will list you in that role instead, and take %(name)s off the filing."), { + name: partyName, + role: role + }, true) : + interpolate( + gettext("We will list you in this case in place of %(name)s, and take them off the filing."), { + name: partyName + }, true); + + helping.hidden = !replacesAName; + if (replacesAName) { + helpingDetail.textContent = interpolate( + gettext("Only do this if %(name)s is you, written differently. If %(name)s is someone else and you are filing on their behalf, they should stay on the filing and you should tell us you are filing for them."), { + name: partyName + }, true); + } + + nameChoice.hidden = !replacesAName; + nameError.hidden = true; + choiceInputs().forEach((input) => { + input.checked = false; + }); + if (replacesAName) { + nameMine.textContent = interpolate(gettext("Use my name: %(name)s"), { + name: filerName + }, true); + nameTheirs.textContent = interpolate( + gettext("Keep the name already in this case: %(name)s"), { + name: partyName + }, true); + } + + if (typeof dialog.showModal === "function") { + dialog.showModal(); + } else { + dialog.setAttribute("open", "open"); + } + } + + function close() { + activeForm = null; + if (typeof dialog.close === "function") { + dialog.close(); + } else { + dialog.removeAttribute("open"); + } + } + + forms.forEach((form) => { + form.addEventListener("submit", (event) => { + if (form.dataset.confirmed === "true") return; + event.preventDefault(); + open(form); + }); + }); + + confirmButton.addEventListener("click", () => { + if (!activeForm) return; + const replacesAName = activeForm.dataset.replacesAName === "true"; + let choice = "mine"; + if (replacesAName) { + const chosen = choiceInputs().find((input) => input.checked); + if (!chosen) { + nameError.hidden = false; + return; + } + choice = chosen.value; + } + const field = activeForm.querySelector('input[name="name_choice"]'); + if (field) field.value = choice; + activeForm.dataset.confirmed = "true"; + const form = activeForm; + close(); + form.submit(); + }); + + cancelButton.addEventListener("click", close); + dialog.addEventListener("cancel", close); + + // The way out for the misreading this dialog exists to catch: keep the + // party on the filing and answer the role question the other way instead. + if (insteadButton) { + insteadButton.addEventListener("click", () => { + const partyId = activeForm ? activeForm.dataset.partyId : ""; + close(); + const notAParty = document.getElementById("filer-not-a-party"); + const filingFor = document.querySelector(`input[name="filing_for"][value="${partyId}"]`); + if (notAParty) { + notAParty.checked = true; + notAParty.dispatchEvent(new Event("change", { + bubbles: true + })); + } + if (filingFor) filingFor.checked = true; + const target = notAParty || filingFor; + if (target) { + target.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + target.focus(); + } + }); + } +})(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/components/claim_party_dialog.html b/efile_app/efile/templates/efile/components/claim_party_dialog.html new file mode 100644 index 00000000..e70c9a09 --- /dev/null +++ b/efile_app/efile/templates/efile/components/claim_party_dialog.html @@ -0,0 +1,57 @@ +{% load i18n %} +{% comment %} +Confirms "this party is me", which replaces a person already in the case with +the person signed in. Two things it has to get across, because getting either +wrong changes who the court thinks the case is about: + +* whose name the court ends up seeing, when the two are not the same name +* that this is not how you say you are *helping* that party file + +Populated from the clicked row by claim-party.js, which also submits it. +{% endcomment %} + +
+

{% translate "Replace this party with you?" %}

+

+ + + +
+ + +
+
+
diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index 3a7aaadb..ee7791bf 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -147,11 +147,6 @@

{% translate "Party list" %}

{% translate "Add me as a party" %} {% endif %} - {% if not filer.party_type and roster|length > 1 %} -

- {% translate "If one of the people below is you, choose \"This is me\" rather than adding yourself again." %} -

- {% endif %}
{% csrf_token %} @@ -161,6 +156,11 @@

{% translate "Party list" %}

+ {% if not filer.party_type and roster|length > 1 %} +

+ {% translate "If one of the people below is you, say so on their row rather than adding yourself again." %} +

+ {% endif %} {% for item in roster %}
@@ -195,15 +195,32 @@

{% translate "Party list" %}

{% if item.party.role == "other" %} {% if not filer.party_type %} - {# The court's required parties are often all taken by people the + {% comment %} + The court's required parties are often all taken by people the document named, so becoming one of them is claiming a row rather - than adding another person. #} -
+ than adding another person. Claiming a row whose name is not + yours replaces a person in the case, so it is not offered under + the same words, and claim-party.js asks before it happens. + {% endcomment %} + {% csrf_token %} + - +
{% endif %} {% translate "Party list" %} {% endif %} + {% include "efile/components/claim_party_dialog.html" %} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index 1eb16809..68ff6cf7 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -209,24 +209,40 @@

{% if not filer_is_party %} - {# Offered for a party the document named as much as for a blank row: + {% comment %} + Offered for a party the document named as much as for a blank row: a filer looking at a detected party is exactly who needs to say that - it is them, and their own name and address are already on the draft. #} + it is them, and their own name and address are already on the draft. + {% endcomment %}
+ class="party-details__its-me claim-party-form" + data-party-id="{{ party.pk }}" + data-party-name="{{ party_name }}" + data-party-role="{{ party.party_type_name }}" + data-filer-name="{{ filer_display_name }}" + data-replaces-a-name="{{ replaces_a_name|yesno:'true,false' }}"> {% csrf_token %} + - + - {% translate "We will list you in the case as this party. We already have your name and address, so there is nothing more to fill in here." %} + {% translate "We will list you in the case in this role. We already have your name and address, so there is nothing more to fill in here." %}
{% endif %} + {% include "efile/components/claim_party_dialog.html" %} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py index f97c99ef..7107ccbd 100644 --- a/efile_app/efile/tests/test_filing_on_behalf.py +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -609,7 +609,7 @@ def test_every_listed_party_can_be_claimed_as_you(client, draft): claimable = set(re.findall(r'name="party_id" value="(\d+)"', content)) assert claimable == {str(tenant.pk), str(landlord.pk)} - assert content.count(">This is me") == 2 + assert content.count("Replace with me") == 2 # And the list says so, for a filer who would otherwise add themselves again. assert "If one of the people below is you" in content @@ -619,7 +619,7 @@ def test_claiming_a_detected_party_takes_their_role_and_drops_the_row(client, dr filer = make_filer(draft) tenant, landlord = both_sides(draft) - post_parties(client, action="claim_party", party_id=tenant.pk) + post_parties(client, action="claim_party", party_id=tenant.pk, name_choice="mine") filer.refresh_from_db() assert filer.party_type == "defendant" @@ -653,7 +653,7 @@ def test_the_party_details_screen_offers_it_for_a_party_with_a_name(client, draf url = f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={tenant.pk}" content = client.get(url).content.decode() - assert "This party is me" in content + assert "Replace this party with me" in content @pytest.mark.django_db @@ -670,3 +670,108 @@ def test_claiming_a_party_with_no_role_yet_leaves_the_role_question_to_answer(cl assert filer.party_type == "" assert filer.is_filing_party is False assert not FilingParty.objects.filter(pk=blank.pk).exists() + + +# --- Claiming a party who is not your name ----------------------------------- + + +@pytest.mark.django_db +def test_replacing_a_differently_named_party_needs_an_answer_about_the_name(client, draft): + """It changes who the court is told the case is about. One click is not + enough for that, so the screen asks and the server holds out for it.""" + + filer = make_filer(draft) + tenant, _landlord = both_sides(draft) + + response = post_parties(client, action="claim_party", party_id=tenant.pk) + + filer.refresh_from_db() + tenant.refresh_from_db() + assert response.status_code == 302 + assert filer.party_type == "" + assert FilingParty.objects.filter(pk=tenant.pk).exists() + assert "Say which name the court should see" in client.get(PARTIES_URL, follow=True).content.decode() + + +@pytest.mark.django_db +def test_keeping_the_name_already_in_the_case_renames_the_filers_own_row(client, draft): + """For the filer whose caption name is not the one on their account: the + court keeps seeing the name it already has.""" + + filer = make_filer(draft, first_name="Q", last_name="Steenhuis") + tenant, _landlord = both_sides(draft) + + post_parties(client, action="claim_party", party_id=tenant.pk, name_choice="theirs") + + filer.refresh_from_db() + assert (filer.first_name, filer.last_name) == ("Real", "Tenant") + assert filer.party_type == "defendant" + assert not FilingParty.objects.filter(pk=tenant.pk).exists() + + +@pytest.mark.django_db +def test_using_your_own_name_replaces_the_one_in_the_case(client, draft): + filer = make_filer(draft, first_name="Q", last_name="Steenhuis") + tenant, _landlord = both_sides(draft) + + post_parties(client, action="claim_party", party_id=tenant.pk, name_choice="mine") + + filer.refresh_from_db() + assert (filer.first_name, filer.last_name) == ("Q", "Steenhuis") + assert filer.party_type == "defendant" + assert not FilingParty.objects.filter(pk=tenant.pk).exists() + + +@pytest.mark.django_db +def test_claiming_a_party_who_is_already_your_name_asks_nothing(client, draft): + """There is no name question when both rows say the same thing.""" + + filer = make_filer(draft, first_name="Jamie", last_name="Rivera") + twin = make_party(draft, 0, first_name="Jamie", last_name="Rivera") + + post_parties(client, action="claim_party", party_id=twin.pk) + + filer.refresh_from_db() + assert filer.party_type == "defendant" + assert not FilingParty.objects.filter(pk=twin.pk).exists() + + +@pytest.mark.django_db +def test_the_two_actions_are_not_offered_under_the_same_words(client, draft): + """ "This is me" and "replace a person in the case with me" are different + things, and a filer should be able to tell which one a button does.""" + + make_filer(draft, first_name="Jamie", last_name="Rivera") + make_party(draft, 0, first_name="Jamie", last_name="Rivera") + make_party( + draft, + 1, + party_type="plaintiff", + party_type_name="Plaintiff", + first_name="Someone", + last_name="Else", + ) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert content.count("This is me") == 1 + assert content.count("Replace with me") == 1 + + +@pytest.mark.django_db +def test_the_confirmation_carries_what_it_needs_to_ask_about(client, draft): + """The dialog is filled in from the row, so the row has to say who is + being replaced, by whom, and whether that is a rename at all.""" + + make_filer(draft, first_name="Q", last_name="Steenhuis") + tenant, _landlord = both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert 'id="claim-party-dialog"' in content + assert 'data-party-name="Real Tenant"' in content + assert 'data-filer-name="Q Steenhuis"' in content + assert 'data-replaces-a-name="true"' in content + assert "Are you helping this person file, rather than being them?" in content diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 1059f3f9..8227e374 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -16,6 +16,7 @@ absorb_filer_duplicates, apply_party_sides, claim_party_as_filer, + claim_replaces_a_name, discard_empty_parties, ensure_required_parties, filer_name_match, @@ -133,7 +134,18 @@ def parties(request, jurisdiction): # name and an address, so the other row goes rather than reaching # the court as a second person. party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") - claim_party_as_filer(draft, party) + name_choice = request.POST.get("name_choice", "") + if claim_replaces_a_name(filer, party) and name_choice not in {"mine", "theirs"}: + # Replacing a differently-named party changes who the court is + # told this case is about. Nobody should be able to do that by + # clicking one button, so the screen asks first and this is + # what happens when the answer did not arrive. + messages.error( + request, + "Say which name the court should see before replacing a party with yourself.", + ) + return redirect(_parties_url(jurisdiction, return_to)) + claim_party_as_filer(draft, party, use_party_name=name_choice == "theirs") filer.refresh_from_db() if filer.party_type: messages.success( @@ -183,7 +195,15 @@ def parties(request, jurisdiction): messages.error(request, "Choose your role in this case, or tell us you are filing for someone else.") roster = [ - {"party": party, "complete": party_is_complete(party, party_types=party_types)} + { + "party": party, + "name": party_display_name(party), + "complete": party_is_complete(party, party_types=party_types), + # Whether claiming this row is confirming who you are or replacing + # somebody with a different name. The two are not the same action + # and the screen does not call them the same thing. + "replaces_a_name": claim_replaces_a_name(filer, party), + } for party in FilingParty.objects.filter(draft=draft) ] saved_filing_for = { @@ -216,6 +236,7 @@ def parties(request, jurisdiction): "roster": roster, "guessed_party_type": guessed_party_type, "not_a_party_value": NOT_A_PARTY, + "filer_display_name": party_display_name(filer), "named_in_document": named_in_document, "named_in_document_name": party_display_name(named_in_document) if named_in_document else "", "named_in_document_role": ( diff --git a/efile_app/efile/views/party_details.py b/efile_app/efile/views/party_details.py index c8eaa6ef..a9fe8ea5 100644 --- a/efile_app/efile/views/party_details.py +++ b/efile_app/efile/views/party_details.py @@ -8,8 +8,10 @@ from efile.party_sides import PartySide, side_for_party_type_name from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot +from efile.services.extracted_parties import party_display_name from efile.services.party_requirements import address_is_blank, party_address_requirement from efile.services.people import ( + claim_replaces_a_name, filer_is_party, get_case_questions, get_party_types, @@ -31,6 +33,7 @@ def party_details(request, jurisdiction): workflow_version=2, ) party = get_object_or_404(FilingParty, draft=draft, role="other", pk=request.GET.get("party")) + filer_row = FilingParty.objects.filter(draft=draft, role="filer").first() party_types = get_party_types(draft) party_type_names = {item["code"]: item["name"] for item in party_types} show_optional_address = not address_is_blank(party) @@ -117,6 +120,12 @@ def party_details(request, jurisdiction): "party": party, # Someone already listed in the case has no use for "this party is me". "filer_is_party": filer_is_party(draft), + "party_name": party_display_name(party), + "filer_display_name": party_display_name(filer_row) if filer_row else "", + # Claiming a row whose name is not the filer's replaces a person in + # the case rather than confirming who they are, and the two are not + # offered under the same words. + "replaces_a_name": claim_replaces_a_name(filer_row, party), "party_types": party_types, "party_kind": "organization" if party.organization_name else "person", "return_to": request.GET.get("return_to", ""), From 46d55ee6a7f71ff59cf4fce967e6de268d630bfd Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 22:40:39 -0400 Subject: [PATCH 5/8] Say "this is me" on the screen that reads the document The people in a case are first shown two screens before the one that asks the filer their own role, and that is the moment someone looks at a name the system read off their complaint and thinks "that is me". Until now there was nothing to do about it there, and the answer had to wait for a screen that had forgotten the question was obvious. Each person on the review screen now carries "This is me". FilingParty.is_self records it -- on the party, because the filer has no row of their own until two screens later -- and the parties screen spends it: the role question comes back already answered and the duplicate already folded in. When the two names disagree the fold waits, because replacing one name with another is the same question wherever it was asked from, and it goes through the same confirmation as any other claim. Only one person can be the filer, so choosing a row un-chooses whichever held it. The answer rides on a hidden value rather than a checkbox, so an unticked row still posts one and stays aligned with the names beside it -- the same reason every other field on that editor posts from every row. With that answered earlier, the parties screen stops pretending it is the first time. It says the list is there to be checked rather than filled in, and it no longer offers "based on this filing, you are likely the Plaintiff" over the top of people the filer has already named -- a guess that contradicts a better answer they already gave. Both routes into that screen are worded for. A filer who turned AI off gets keyword_document_analysis, which reads a form number and a case number and never a name, so their party list is entirely their own typing: they are told "these are the people you told us about", and only a list that actually came off the document is credited to having read it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- .../efile/migrations/0021_party_is_self.py | 18 +++ efile_app/efile/models.py | 7 ++ efile_app/efile/services/extracted_parties.py | 15 ++- efile_app/efile/services/people.py | 36 +++++- .../efile/static/css/reorganized-flow.css | 8 +- .../efile/static/js/extraction-review.js | 34 ++++++ .../templates/efile/extraction_review.html | 22 +++- efile_app/efile/templates/efile/parties.html | 40 ++++++- .../efile/tests/test_extracted_parties.py | 66 +++++++++++ .../efile/tests/test_filing_on_behalf.py | 107 +++++++++++++++++- efile_app/efile/views/extraction_review.py | 10 +- efile_app/efile/views/parties.py | 32 +++++- 12 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 efile_app/efile/migrations/0021_party_is_self.py diff --git a/efile_app/efile/migrations/0021_party_is_self.py b/efile_app/efile/migrations/0021_party_is_self.py new file mode 100644 index 00000000..a6904f12 --- /dev/null +++ b/efile_app/efile/migrations/0021_party_is_self.py @@ -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), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index a452e4e7..f463f80c 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -398,6 +398,13 @@ 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 diff --git a/efile_app/efile/services/extracted_parties.py b/efile_app/efile/services/extracted_parties.py index aa365804..0a6c26aa 100644 --- a/efile_app/efile/services/extracted_parties.py +++ b/efile_app/efile/services/extracted_parties.py @@ -257,11 +257,19 @@ 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, } 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, + } for entry in extracted_party_suggestions(draft.extracted_guesses) ] @@ -278,6 +286,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() @@ -305,6 +315,9 @@ 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 + is_self = str(row.get("is_self", "")).lower() == "true" and not claimed_self + claimed_self = claimed_self or is_self + party.is_self = is_self party.save() for pk, party in existing.items(): diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index fc52c60d..1cf7f6c2 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -10,7 +10,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.document_checklists import party_type_keywords_for_role -from efile.services.extracted_parties import party_display_name +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 from efile.utils.config_loader import config_loader from efile.workflow import ExistingCase @@ -103,6 +103,40 @@ def set_filing_parties(draft: FilingDraft, parties) -> None: party.save(update_fields=["is_filing_party", "updated_at"]) +def self_claimed_party(draft: FilingDraft) -> FilingParty | None: + """The party the filer ticked as themselves while reviewing the document.""" + + if filer_is_party(draft): + return None + return FilingParty.objects.filter(draft=draft, role="other", is_self=True).first() + + +def case_has_named_parties(draft: FilingDraft) -> bool: + """Whether the case's other people have been settled already. + + Once they have, a suggestion about which side the filer is probably on is + not a help but a contradiction: it is guessing at something the filer has + already been asked and answered. True however they were settled -- read + off the document, or typed in by hand. + """ + + return any(party_display_name(party) for party in FilingParty.objects.filter(draft=draft, role="other")) + + +def document_named_the_parties(draft: FilingDraft) -> bool: + """Whether the people in this case came off the document or out of a form. + + A filer who turned AI off gets a keyword scan, which reads a form number + and a case number and never a name (see + ``document_extractions.keyword_document_analysis``), so on that route the + party list is entirely their own typing. Screens that would otherwise + credit a reading have to know the difference, or they tell the filer the + system did something for them that it did not do. + """ + + return bool(extracted_party_suggestions(draft.extracted_guesses)) + + def filer_name_match(draft: FilingDraft) -> FilingParty | None: """The caption party who has the filer's own name, when they are not one. diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 10e10cf4..47bd38b0 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -577,7 +577,13 @@ align-items: end; display: grid; gap: 0.75rem; - grid-template-columns: minmax(0, 2fr) minmax(0, 1.4fr) auto; + /* Name, side, "this is me", remove. */ + grid-template-columns: minmax(0, 2fr) minmax(0, 1.4fr) auto auto; +} + +/* Sits on the baseline of the two fields beside it rather than above them. */ +.review-party__is-me { + padding-bottom: 0.15rem; } .review-party label { diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 32716842..c882b0c5 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -341,7 +341,38 @@ partyEmpty.hidden = partyList.children.length > 0; } + // "This is me" on one of the people the document named. At most one of + // them can be, so choosing a row un-chooses whichever held it before. + // The answer rides on a hidden value rather than a checkbox, so every row + // posts one and the lists the view reads back stay index-aligned. + function setIsMe(row, isMe) { + const field = row.querySelector('input[name="party_is_self"]'); + const toggle = row.querySelector(".review-party__is-me-toggle"); + if (!field || !toggle) return; + field.value = isMe ? "true" : "false"; + toggle.setAttribute("aria-pressed", isMe ? "true" : "false"); + toggle.classList.toggle("btn-outline-secondary", !isMe); + toggle.classList.toggle("btn-primary", isMe); + } + + function syncIsMeButtons() { + Array.from(partyList.querySelectorAll(".review-party")).forEach((row) => { + const field = row.querySelector('input[name="party_is_self"]'); + setIsMe(row, Boolean(field) && field.value === "true"); + }); + } + partyList.addEventListener("click", (event) => { + const isMeToggle = event.target.closest(".review-party__is-me-toggle"); + if (isMeToggle) { + const row = isMeToggle.closest(".review-party"); + const field = row.querySelector('input[name="party_is_self"]'); + const turningOn = !field || field.value !== "true"; + Array.from(partyList.querySelectorAll(".review-party")).forEach((other) => { + setIsMe(other, turningOn && other === row); + }); + return; + } const removeButton = event.target.closest(".review-party__remove"); if (!removeButton) return; const row = removeButton.closest(".review-party"); @@ -352,9 +383,12 @@ syncPartyEmptyState(); }); + syncIsMeButtons(); + addPartyButton.addEventListener("click", () => { partyList.appendChild(partyTemplate.content.cloneNode(true)); syncPartyEmptyState(); + syncIsMeButtons(); partyList.lastElementChild.querySelector('input[name="party_name"]').focus(); }); diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 4a8d194a..06c7f35c 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -242,7 +242,11 @@

{% translate "Tell us about your case" %}

{% translate "People named in your document" %}

- {% translate "Fix any name we misread, and say which side each person is on. You will add addresses later." %} + {% if party_rows %} + {% translate "Fix any name we got wrong, and say which side each person is on. If one of them is you, say so here and we will not ask you to enter yourself again. You will add addresses later." %} + {% else %} + {% translate "Add the people involved in this case, and say which side each one is on. If one of them is you, say so here and we will not ask you to enter yourself again. You will add addresses later." %} + {% endif %}

    {% for row in party_rows %} @@ -278,6 +282,16 @@

    {% translate "Tell us about your case" %}

    {% endfor %} +
    + + +
    + + {% translate "If it is a different person with the same name, leave them on the list and answer for yourself below." %} @@ -156,7 +175,18 @@

    {% translate "Party list" %}

    - {% if not filer.party_type and roster|length > 1 %} + {% if case_has_parties %} +

    + {% if parties_from_document %} + {% translate "These are the people we read from your document and you confirmed. Change anything that is wrong." %} + {% else %} + {% translate "These are the people you told us about. Change anything that is wrong." %} + {% endif %} + {% if not filer.party_type %} + {% translate "If one of them is you, say so on their row rather than adding yourself again." %} + {% endif %} +

    + {% elif not filer.party_type and roster|length > 1 %}

    {% translate "If one of the people below is you, say so on their row rather than adding yourself again." %}

    diff --git a/efile_app/efile/tests/test_extracted_parties.py b/efile_app/efile/tests/test_extracted_parties.py index 7561f7a1..1547e94a 100644 --- a/efile_app/efile/tests/test_extracted_parties.py +++ b/efile_app/efile/tests/test_extracted_parties.py @@ -491,3 +491,69 @@ def test_a_placeholder_typed_into_the_review_screen_adds_nobody(review_draft): names = [party.last_name for party in FilingParty.objects.filter(draft=review_draft, role="other")] assert names == ["Lee"] + + +# --- "This is me", said on the screen that reads the document ---------------- + + +@pytest.mark.django_db +def test_the_review_screen_records_which_person_is_the_filer(client, review_draft): + authorize(client, review_draft) + + client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "reviewed_extraction": "yes", + "existing_case": ExistingCase.NEW, + "court_code": "cook:cvd1", + "case_category_code": "civil", + "case_type_code": "NC", + "party_id": ["", ""], + "party_name": ["Alex Rivera", "Morgan Lee"], + "party_side": [PartySide.INITIATING, PartySide.RESPONDING], + "party_role_hint": ["", ""], + "party_is_self": ["false", "true"], + }, + ) + + parties = list(FilingParty.objects.filter(draft=review_draft, role="other").order_by("sort_order")) + assert [(party.last_name, party.is_self) for party in parties] == [("Rivera", False), ("Lee", True)] + + +@pytest.mark.django_db +def test_only_one_person_can_be_the_filer(client, review_draft): + """However many rows say so -- there is one person signed in.""" + + authorize(client, review_draft) + + client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "reviewed_extraction": "yes", + "existing_case": ExistingCase.NEW, + "court_code": "cook:cvd1", + "case_category_code": "civil", + "case_type_code": "NC", + "party_id": ["", ""], + "party_name": ["Alex Rivera", "Morgan Lee"], + "party_side": [PartySide.INITIATING, PartySide.RESPONDING], + "party_role_hint": ["", ""], + "party_is_self": ["true", "true"], + }, + ) + + marked = FilingParty.objects.filter(draft=review_draft, role="other", is_self=True) + assert [party.last_name for party in marked] == ["Rivera"] + + +@pytest.mark.django_db +def test_the_review_screen_offers_the_tick_on_every_person_it_found(client, review_draft): + authorize(client, review_draft) + + content = client.get(reverse("extraction_review", kwargs={"jurisdiction": "illinois"})).content.decode() + + listing = re.search(r'id="review-parties-list">(.*?)
', content, re.S) + assert listing is not None + assert listing.group(1).count('name="party_is_self"') == 4 + assert "This is me" in content + assert "If one of them is you, say so here" in content diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py index 7107ccbd..c2d1f2f7 100644 --- a/efile_app/efile/tests/test_filing_on_behalf.py +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -611,7 +611,7 @@ def test_every_listed_party_can_be_claimed_as_you(client, draft): assert claimable == {str(tenant.pk), str(landlord.pk)} assert content.count("Replace with me") == 2 # And the list says so, for a filer who would otherwise add themselves again. - assert "If one of the people below is you" in content + assert "If one of them is you" in content @pytest.mark.django_db @@ -775,3 +775,108 @@ def test_the_confirmation_carries_what_it_needs_to_ask_about(client, draft): assert 'data-filer-name="Q Steenhuis"' in content assert 'data-replaces-a-name="true"' in content assert "Are you helping this person file, rather than being them?" in content + + +# --- Saying "this is me" while reading the document -------------------------- + + +@pytest.mark.django_db +def test_a_party_ticked_as_you_on_the_document_screen_needs_no_second_answer(client, draft): + """The whole point of ticking it there: by the time the role question is + asked, it has been answered.""" + + filer = make_filer(draft, first_name="Real", last_name="Tenant") + tenant, landlord = both_sides(draft) + tenant.is_self = True + tenant.save(update_fields=["is_self"]) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + filer.refresh_from_db() + assert filer.party_type == "defendant" + assert filer.is_filing_party is True + assert not FilingParty.objects.filter(pk=tenant.pk).exists() + assert FilingParty.objects.filter(pk=landlord.pk).exists() + assert filing_parties(draft) == [filer] + assert "Is this you?" not in content + + +@pytest.mark.django_db +def test_a_tick_on_a_differently_named_party_is_confirmed_rather_than_applied(client, draft): + """Replacing one name with another is the same question wherever it was + asked from, and it does not get to skip being asked.""" + + filer = make_filer(draft, first_name="Q", last_name="Steenhuis") + tenant, _landlord = both_sides(draft) + tenant.is_self = True + tenant.save(update_fields=["is_self"]) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + filer.refresh_from_db() + assert filer.party_type == "" + assert FilingParty.objects.filter(pk=tenant.pk).exists() + assert "You said Real Tenant is you" in content + assert 'data-replaces-a-name="true"' in content + + +@pytest.mark.django_db +def test_the_likely_role_guess_is_not_offered_against_parties_already_named(client, draft): + """It contradicts an answer the filer has already given, on a screen that + knew more than the guess does.""" + + make_filer(draft) + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert 'id="apply-party-type-guess"' not in content + assert "you are likely the" not in content + + +@pytest.mark.django_db +def test_the_guess_is_still_offered_when_nobody_has_been_named(client, draft): + """With nothing else to go on, the case posture is the only help there is.""" + + make_filer(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert 'id="apply-party-type-guess"' in content + + +@pytest.mark.django_db +def test_the_screen_says_it_is_checking_work_already_done(client, draft): + make_filer(draft) + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + content = client.get(PARTIES_URL).content.decode() + + assert "You already told us who is in this case" in content + + +@pytest.mark.django_db +def test_the_screen_credits_the_document_only_when_the_document_named_anyone(client, draft): + """A filer who turned AI off got a keyword scan, which reads a form number + and never a name -- so on that route the list is entirely their own typing + and the screen must not tell them we read it off their document.""" + + make_filer(draft) + both_sides(draft) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + typed_in = client.get(PARTIES_URL).content.decode() + + draft.extracted_guesses = {"defendant or respondent names": "Real Tenant"} + draft.save(update_fields=["extracted_guesses"]) + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + read_off = client.get(PARTIES_URL).content.decode() + + assert "These are the people you told us about" in typed_in + assert "we read from your document" not in typed_in + assert "These are the people we read from your document" in read_off diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 546a3866..8efc1d4b 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -42,15 +42,18 @@ def _offered_filer_roles(request, jurisdiction): def _submitted_party_rows(request): """Read the party editor back off the form, keeping its rows aligned. - Every row posts all four of its inputs, including the empty id of a row - the filer just added, so the four lists stay index-aligned even when rows - were added or removed in the browser. + Every row posts all of its inputs, including the empty id of a row the + filer just added, so the lists stay index-aligned even when rows were + added or removed in the browser. """ ids = request.POST.getlist("party_id") names = request.POST.getlist("party_name") sides = request.POST.getlist("party_side") hints = request.POST.getlist("party_role_hint") + # A hidden value rather than a checkbox, so an unticked row still posts + # something and the lists stay index-aligned with the names beside them. + selves = request.POST.getlist("party_is_self") rows = [] for index, name in enumerate(names): raw_id = ids[index] if index < len(ids) else "" @@ -60,6 +63,7 @@ def _submitted_party_rows(request): "name": name, "side": sides[index] if index < len(sides) else "", "role_hint": hints[index] if index < len(hints) else "", + "is_self": selves[index] if index < len(selves) else "", } ) return rows diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 8227e374..02bc4061 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -15,9 +15,11 @@ NOT_A_PARTY, absorb_filer_duplicates, apply_party_sides, + case_has_named_parties, claim_party_as_filer, claim_replaces_a_name, discard_empty_parties, + document_named_the_parties, ensure_required_parties, filer_name_match, filing_party_candidates, @@ -25,8 +27,10 @@ get_party_types, guess_filer_party_type, incomplete_parties, + names_match, needs_amount_in_controversy, party_is_complete, + self_claimed_party, set_filing_parties, ) from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to @@ -108,6 +112,14 @@ def parties(request, jurisdiction): # duplicate in first keeps them from reaching the court twice. absorb_filer_duplicates(draft) apply_party_sides(draft, party_types) + # The answer given two screens ago, now that there is a filer row to give + # it to and a court party type to give them. Folded straight in when the + # two rows agree on the name; when they do not, replacing one name with + # another is a question, and it is put below rather than done quietly. + marked_self = self_claimed_party(draft) + if marked_self is not None and names_match(filer, marked_self): + claim_party_as_filer(draft, marked_self) + filer.refresh_from_db() if request.method == "POST": action = request.POST.get("action", "continue") @@ -215,9 +227,15 @@ def parties(request, jurisdiction): # role question is still unanswered -- a filer who has said they are # filing for someone else has answered it, and does not need telling again # every time they come back to this screen. - named_in_document = None if saved_filing_for else filer_name_match(draft) + marked_self = self_claimed_party(draft) + named_in_document = None if saved_filing_for else (marked_self or filer_name_match(draft)) + # Never alongside the people themselves: a filer who has said who is in + # this case has answered a better version of this question already, and + # being told what they are "likely" to be contradicts it. guessed_party_type = ( - None if filer.party_type or named_in_document is not None else guess_filer_party_type(draft, party_types) + None + if filer.party_type or named_in_document is not None or case_has_named_parties(draft) + else guess_filer_party_type(draft, party_types) ) # Which branch of the role question the screen comes back on. A filer who # has never answered gets neither pre-selected -- their own role is not @@ -238,6 +256,16 @@ def parties(request, jurisdiction): "not_a_party_value": NOT_A_PARTY, "filer_display_name": party_display_name(filer), "named_in_document": named_in_document, + # Whether that came from the filer ticking "this is me" while reading + # their document, which is worth saying back to them in those words. + "named_in_document_was_claimed": marked_self is not None and named_in_document == marked_self, + # Two different routes reach this screen with a party list: the + # document named these people and the filer confirmed them, or the + # filer typed them all in because they turned AI off and the keyword + # scan never reads names. The screen must not credit the first when + # it was the second. + "case_has_parties": case_has_named_parties(draft), + "parties_from_document": document_named_the_parties(draft), "named_in_document_name": party_display_name(named_in_document) if named_in_document else "", "named_in_document_role": ( (named_in_document.party_type_name or PARTY_SIDE_LABELS.get(named_in_document.party_side, "")) From 078381587d8b73144ff5ddba7fda5736a50e9d96 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 1 Sep 2026 22:48:43 -0400 Subject: [PATCH 6/8] Never offer a company as the person filing "Replace with me" sat next to Fox River Phone Repair LLC on the party list, which no individual can truthfully say -- accounts register as individuals (views.register sends registrationType: INDIVIDUAL), and a landlord whose LLC is the plaintiff wants to file *for* the company. It was also a way to break the filing. Claiming a company and keeping its name would have made the filer's own row an organization, and the filer's row reaches Tyler through accountUser(), which splits one name into a first and a last and never says the party is a business -- the same missing person_type that answered a fee quote with "PersonSurName is required" a few commits ago, arrived at from the other direction. Refused in the view as well as hidden on both screens, so it is not only the button that is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- efile_app/efile/services/people.py | 14 ++++++++++ efile_app/efile/templates/efile/parties.html | 2 +- .../efile/templates/efile/party_details.html | 2 +- .../efile/tests/test_filing_on_behalf.py | 28 +++++++++++++++++-- efile_app/efile/views/parties.py | 10 ++++++- efile_app/efile/views/party_details.py | 2 ++ 6 files changed, 52 insertions(+), 6 deletions(-) diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index 1cf7f6c2..acbf4954 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -164,6 +164,20 @@ def names_match(filer: FilingParty | None, party: FilingParty | None) -> bool: return bool(filer_name) and filer_name == _comparable(party_display_name(party)) +def party_can_be_the_filer(party: FilingParty | None) -> bool: + """Whether "this party is me" is a thing anyone could truthfully say of it. + + Never of an organization. Accounts are registered as individuals + (``views.register`` sends ``registrationType: INDIVIDUAL``), so a person + claiming a company is always the wrong action -- and a costly one, because + the filer's row would become the company and reach Tyler through the + account-user path, which has no way to say it is a business. A landlord + whose LLC is the plaintiff wants to file *for* the company instead. + """ + + return party is not None and not party.organization_name + + def claim_replaces_a_name(filer: FilingParty | None, party: FilingParty | None) -> bool: """Whether claiming this party would put a different name in the case. diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html index cdccc3fe..dc833205 100644 --- a/efile_app/efile/templates/efile/parties.html +++ b/efile_app/efile/templates/efile/parties.html @@ -224,7 +224,7 @@

{% translate "Party list" %}

{% endif %} {% if item.party.role == "other" %} - {% if not filer.party_type %} + {% if not filer.party_type and item.claimable %} {% comment %} The court's required parties are often all taken by people the document named, so becoming one of them is claiming a row rather diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index 68ff6cf7..55b16ccd 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -208,7 +208,7 @@

- {% if not filer_is_party %} + {% if not filer_is_party and claimable %} {% comment %} Offered for a party the document named as much as for a blank row: a filer looking at a detected party is exactly who needs to say that diff --git a/efile_app/efile/tests/test_filing_on_behalf.py b/efile_app/efile/tests/test_filing_on_behalf.py index c2d1f2f7..8dcd5fd7 100644 --- a/efile_app/efile/tests/test_filing_on_behalf.py +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -607,9 +607,13 @@ def test_every_listed_party_can_be_claimed_as_you(client, draft): with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): content = client.get(PARTIES_URL).content.decode() - claimable = set(re.findall(r'name="party_id" value="(\d+)"', content)) - assert claimable == {str(tenant.pk), str(landlord.pk)} - assert content.count("Replace with me") == 2 + # The company is not offered: a person signed in cannot be one. + # data-party-id is on the claim forms only, unlike the remove forms' + # party_id input, which every row still has. + claimable = set(re.findall(r'data-party-id="(\d+)"', content)) + assert claimable == {str(tenant.pk)} + assert str(landlord.pk) not in claimable + assert content.count("Replace with me") == 1 # And the list says so, for a filer who would otherwise add themselves again. assert "If one of them is you" in content @@ -880,3 +884,21 @@ def test_the_screen_credits_the_document_only_when_the_document_named_anyone(cli assert "These are the people you told us about" in typed_in assert "we read from your document" not in typed_in assert "These are the people we read from your document" in read_off + + +@pytest.mark.django_db +def test_an_organization_is_never_offered_as_you(client, draft): + """Accounts are registered as individuals, so a person claiming a company + is always wrong -- and it would send their row to Tyler through the path + that has no way to say a party is a business.""" + + filer = make_filer(draft) + _tenant, landlord = both_sides(draft) + + post_parties(client, action="claim_party", party_id=landlord.pk, name_choice="theirs") + + filer.refresh_from_db() + assert filer.party_type == "" + assert filer.organization_name == "" + assert FilingParty.objects.filter(pk=landlord.pk).exists() + assert "An organization cannot be you" in client.get(PARTIES_URL, follow=True).content.decode() diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index 02bc4061..f912fa12 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -29,6 +29,7 @@ incomplete_parties, names_match, needs_amount_in_controversy, + party_can_be_the_filer, party_is_complete, self_claimed_party, set_filing_parties, @@ -117,7 +118,7 @@ def parties(request, jurisdiction): # two rows agree on the name; when they do not, replacing one name with # another is a question, and it is put below rather than done quietly. marked_self = self_claimed_party(draft) - if marked_self is not None and names_match(filer, marked_self): + if marked_self is not None and party_can_be_the_filer(marked_self) and names_match(filer, marked_self): claim_party_as_filer(draft, marked_self) filer.refresh_from_db() @@ -146,6 +147,12 @@ def parties(request, jurisdiction): # name and an address, so the other row goes rather than reaching # the court as a second person. party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") + if not party_can_be_the_filer(party): + messages.error( + request, + "An organization cannot be you. Say you are filing for them instead.", + ) + return redirect(f"{_parties_url(jurisdiction, return_to)}#your-role") name_choice = request.POST.get("name_choice", "") if claim_replaces_a_name(filer, party) and name_choice not in {"mine", "theirs"}: # Replacing a differently-named party changes who the court is @@ -215,6 +222,7 @@ def parties(request, jurisdiction): # somebody with a different name. The two are not the same action # and the screen does not call them the same thing. "replaces_a_name": claim_replaces_a_name(filer, party), + "claimable": party_can_be_the_filer(party), } for party in FilingParty.objects.filter(draft=draft) ] diff --git a/efile_app/efile/views/party_details.py b/efile_app/efile/views/party_details.py index a9fe8ea5..efe42f7a 100644 --- a/efile_app/efile/views/party_details.py +++ b/efile_app/efile/views/party_details.py @@ -17,6 +17,7 @@ get_party_types, incomplete_parties, needs_amount_in_controversy, + party_can_be_the_filer, ) from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to @@ -120,6 +121,7 @@ def party_details(request, jurisdiction): "party": party, # Someone already listed in the case has no use for "this party is me". "filer_is_party": filer_is_party(draft), + "claimable": party_can_be_the_filer(party), "party_name": party_display_name(party), "filer_display_name": party_display_name(filer_row) if filer_row else "", # Claiming a row whose name is not the filer's replaces a person in From 327eb47db9ae535b4f3b9a557e1eb6cca5ec738e Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Wed, 2 Sep 2026 07:45:13 -0400 Subject: [PATCH 7/8] Do not offer a company the tick on the review screen either The claim button was taken off the party list a commit ago but left on the screen the names first appear on, so a tick on a company was still reachable -- and led to a refusal two screens later, which is a dead end rather than an answer. Hidden there too, and ignored by self_claimed_party in case an older draft carries one. The hidden value still posts from every row. It is what keeps the lists the view reads back lined up with the names beside them, so it is the button that is conditional and never the field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015MLBD8Co7jdvH2ZmtQeEwx --- efile_app/efile/services/extracted_parties.py | 2 ++ efile_app/efile/services/people.py | 3 ++- .../templates/efile/extraction_review.html | 18 +++++++++++++----- .../efile/tests/test_extracted_parties.py | 17 +++++++++++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/efile_app/efile/services/extracted_parties.py b/efile_app/efile/services/extracted_parties.py index 0a6c26aa..5ba42ab8 100644 --- a/efile_app/efile/services/extracted_parties.py +++ b/efile_app/efile/services/extracted_parties.py @@ -258,6 +258,7 @@ def review_rows(draft: FilingDraft) -> list[dict[str, Any]]: "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 ] @@ -269,6 +270,7 @@ def review_rows(draft: FilingDraft) -> list[dict[str, Any]]: "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) ] diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index acbf4954..7cc63646 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -108,7 +108,8 @@ def self_claimed_party(draft: FilingDraft) -> FilingParty | None: if filer_is_party(draft): return None - return FilingParty.objects.filter(draft=draft, role="other", is_self=True).first() + marked = FilingParty.objects.filter(draft=draft, role="other", is_self=True).first() + return marked if party_can_be_the_filer(marked) else None def case_has_named_parties(draft: FilingDraft) -> bool: diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 06c7f35c..0ef46564 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -283,14 +283,22 @@

{% translate "Tell us about your case" %}

+ {% comment %} + The value posts from every row, ticked or not, so the lists the + view reads back stay lined up with the names. Only the button is + conditional: a company can never be the person signed in, whose + account is registered to an individual. + {% endcomment %} - + {% if not row.is_organization %} + + {% endif %}