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 0000000..ce3d49c --- /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/migrations/0020_notice_email.py b/efile_app/efile/migrations/0020_notice_email.py new file mode 100644 index 0000000..44243b1 --- /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/migrations/0021_party_is_self.py b/efile_app/efile/migrations/0021_party_is_self.py new file mode 100644 index 0000000..a6904f1 --- /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 17b8e09..f463f80 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 @@ -390,6 +398,22 @@ class FilingParty(models.Model): party_type_name = models.CharField(max_length=255, blank=True) external_party_id = models.CharField(max_length=255, blank=True) + # Set on the review screen, where the people read off the document are + # first shown: "this one is me". It is recorded on the party rather than + # on the filer because the filer has no row of their own yet -- theirs is + # made two screens later, on your-information -- and the parties screen + # is where the two are finally put together. + is_self = models.BooleanField(default=False) + + # Whether the filing is made *on behalf of* this party -- what Tyler calls + # a filing party, and a different question from who is in the case. The + # person signed in is usually a party themselves, and then their own row + # carries this. But someone can file for a party they are not: a parent + # for a child, a friend helping a neighbour answer an eviction. Their row + # then has no ``party_type`` at all, and the party they are filing for + # carries this instead. See ``efile.services.people.filing_parties``. + is_filing_party = models.BooleanField(default=False) + # Which side of the caption this person is on, in the only vocabulary a # document itself establishes: whoever started the case, whoever is # answering it, or someone else it names. Unlike ``party_type`` -- a code diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 23c16b9..74a0990 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 [])) @@ -309,6 +310,10 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]: { "id": party.pk, "role": party.role, + # Whether the filing is made on behalf of this party -- the filer + # themselves when they are one, someone they are filing for when + # they are not. The payload names these as Tyler's filing parties. + "is_filing_party": party.is_filing_party, "party_type": party.party_type, "party_type_name": party.party_type_name, "first_name": party.first_name, diff --git a/efile_app/efile/services/extracted_parties.py b/efile_app/efile/services/extracted_parties.py index 392d7df..271eef6 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) @@ -181,11 +257,21 @@ def review_rows(draft: FilingDraft) -> list[dict[str, Any]]: "side": party.party_side or side_for_party_type_name(party.party_type_name), "role_hint": party.party_role_hint, "party_type_name": party.party_type_name, + "is_self": party.is_self, + "is_organization": bool(party.organization_name), } for party in saved ] return [ - {"id": "", "name": entry["name"], "side": entry["side"], "role_hint": entry["role_hint"], "party_type_name": ""} + { + "id": "", + "name": entry["name"], + "side": entry["side"], + "role_hint": entry["role_hint"], + "party_type_name": "", + "is_self": False, + "is_organization": looks_like_organization(entry["name"]), + } for entry in extracted_party_suggestions(draft.extracted_guesses) ] @@ -202,6 +288,8 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non existing = {party.pk: party for party in FilingParty.objects.filter(draft=draft, role="other")} kept: set[int] = set() next_order = max((party.sort_order for party in existing.values()), default=-1) + 1 + # Only one of them can be the person filing, however many rows say so. + claimed_self = False for index, row in enumerate(rows): name = row.get("name", "").strip() @@ -212,7 +300,9 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non party = existing.get(row.get("id")) if party is None: - if not name: + # "Unknown" is the document extraction saying it found nobody, not + # a person to add to the case. + if not name or is_placeholder_name(name): continue party = FilingParty(draft=draft, role="other", sort_order=next_order + index) else: @@ -227,6 +317,16 @@ def save_reviewed_parties(draft: FilingDraft, rows: list[dict[str, str]]) -> Non party.party_type_name = "" party.party_side = side party.party_role_hint = role_hint + # An organization can never be the person signed in, whose account is + # registered to an individual -- the rule + # ``people.party_can_be_the_filer`` enforces everywhere else, applied + # here too so the answer is never *stored* on a row that could only be + # ignored later. It also keeps a company off the one self slot: a name + # typed into the same submit that ticked it is only known to be a + # company by the time ``apply_name`` above has run. + is_self = str(row.get("is_self", "")).lower() == "true" and not party.organization_name and not claimed_self + claimed_self = claimed_self or is_self + party.is_self = is_self party.save() for pk, party in existing.items(): diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index d7e835d..7cc6364 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 @@ -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,231 @@ 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 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 + 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: + """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. + + 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 names_match(filer: FilingParty | None, party: FilingParty | None) -> bool: + """Whether two rows are the same name once spelling is set aside.""" + + 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 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. + + 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 + filer.party_role_hint = filer.party_role_hint or party.party_role_hint + # 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=[ + *name_fields, + "party_type", + "party_type_name", + "party_side", + "party_role_hint", + "is_filing_party", + "updated_at", + ] + ) + party.delete() + if filer.is_filing_party: + 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. + + 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,20 +467,44 @@ 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. + + 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) 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/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index b2f3ba3..47bd38b 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 { @@ -1361,11 +1367,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 0000000..1093bd3 --- /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/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 3271684..c882b0c 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/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index 1b5d8f5..39ec89b 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 || "", @@ -78,23 +87,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 +150,68 @@ 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 {string} noticeEmail where notices about this case should go + */ + 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, + // 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 + }; + }, + + 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.'); + } + + // 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; - 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, noticeEmail) + )) : [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 +241,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) { @@ -200,13 +287,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/static/js/parties.js b/efile_app/efile/static/js/parties.js index 50dbc3d..3a5867f 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/components/claim_party_dialog.html b/efile_app/efile/templates/efile/components/claim_party_dialog.html new file mode 100644 index 0000000..e70c9a0 --- /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/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 4a8d194..0ef4656 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,24 @@

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

    {% endfor %} +
    + {% 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 %} +
    + + + {% translate "If it is a different person with the same name, leave them on the list and answer for yourself below." %} + + + {% endif %} +
    {% csrf_token %} @@ -52,7 +89,61 @@

    {% 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 +159,13 @@

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

{% translate "Party list" %}

+ {% if not filer.party_type %} + + {% endif %} {% csrf_token %} @@ -77,6 +175,22 @@

{% translate "Party list" %}

+ {% 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." %} +

+ {% endif %} {% for item in roster %}
@@ -88,9 +202,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 %} @@ -102,6 +224,35 @@

{% translate "Party list" %}

{% endif %} {% if item.party.role == "other" %} + {% 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 + 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 "Edit" %}
@@ -129,7 +280,9 @@

{% 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 ef7914f..55b16cc 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -208,8 +208,41 @@

+ {% 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 + it is them, and their own name and address are already on the draft. + {% endcomment %} +
+ {% csrf_token %} + + + + + + + {% 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/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index 62a8f8b..ae60353 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -112,7 +112,28 @@

{% 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 %} + {% 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 5971a67..27df405 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,203 @@ 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"] + + +# --- "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 + + +@pytest.mark.django_db +def test_a_company_is_not_offered_as_the_person_filing(client, review_draft): + """The account is registered to an individual, so no company is them -- + and the row still posts its value, or the lists stop lining up.""" + + 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 + rows = listing.group(1) + # Four people, one of them Riverbend Properties LLC. + assert rows.count('name="party_is_self"') == 4 + assert rows.count("review-party__is-me-toggle") == 3 + + +@pytest.mark.django_db +def test_a_company_ticked_as_you_is_not_recorded_as_you(client, review_draft): + """Storing it would be storing an answer nothing can act on: a company is + never the person signed in, so the parties screen would ignore it and the + filer would be left wondering where their answer went.""" + + 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": ["Riverbend Properties LLC"], + "party_side": [PartySide.INITIATING], + "party_role_hint": [""], + "party_is_self": ["true"], + }, + ) + + party = FilingParty.objects.get(draft=review_draft, role="other") + assert party.organization_name == "Riverbend Properties LLC" + assert party.is_self is False + + +@pytest.mark.django_db +def test_a_company_does_not_use_up_the_one_slot_for_you(client, review_draft): + """Otherwise the real person on the next row silently loses the tick.""" + + 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": ["Riverbend Properties LLC", "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] == ["Lee"] 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 0000000..8dcd5fd --- /dev/null +++ b/efile_app/efile/tests/test_filing_on_behalf.py @@ -0,0 +1,904 @@ +"""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.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}, +] + + +@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, notice_email=NOTICE_EMAIL) + + 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], notice_email=NOTICE_EMAIL) + + 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, notice_email=NOTICE_EMAIL) + + 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, notice_email=NOTICE_EMAIL) + + 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 "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="claim_party", party_id=blank.pk) + + 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_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", 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, + 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): + 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] + + +@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_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.""" + + 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) + + 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() + + +# --- 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() + + # 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 + + +@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, name_choice="mine") + + 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 "Replace this party with 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() + + +# --- 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 + + +# --- 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 + + +@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/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index 4e79efc..ad07662 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 5a7e832..21a518d 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/extraction_review.py b/efile_app/efile/views/extraction_review.py index 546a386..8efc1d4 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 29dc1a8..f912fa1 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -1,22 +1,38 @@ 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, + 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, get_case_questions, get_party_types, guess_filer_party_type, incomplete_parties, + names_match, needs_amount_in_controversy, + party_can_be_the_filer, 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 @@ -30,6 +46,46 @@ 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.""" + + 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): @@ -46,11 +102,25 @@ 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. 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 party_can_be_the_filer(marked_self) 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") @@ -70,6 +140,40 @@ 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": + # "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") + 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 + # 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( + 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() @@ -77,36 +181,78 @@ def parties(request, jurisdiction): 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) + 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"]) - 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]) + 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.") 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), + "claimable": party_can_be_the_filer(party), + } 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. + 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 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 + # 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. + 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 +261,36 @@ def parties(request, jurisdiction): "party_types": party_types, "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, + # 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, "")) + 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/party_details.py b/efile_app/efile/views/party_details.py index 447616b..efe42f7 100644 --- a/efile_app/efile/views/party_details.py +++ b/efile_app/efile/views/party_details.py @@ -8,12 +8,16 @@ 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, 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 @@ -30,6 +34,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) @@ -114,6 +119,15 @@ 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), + "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 + # 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", ""), diff --git a/efile_app/efile/views/payment.py b/efile_app/efile/views/payment.py index fca982b..c110fcd 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 1171b5d..623bb38 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,17 @@ 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") + ], + # 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 0761523..870a84c 100644 --- a/efile_app/js-tests/filing-payload.test.js +++ b/efile_app/js-tests/filing-payload.test.js @@ -344,4 +344,266 @@ 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]"] + ); +}); +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"); +}); +// -- 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