diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index cd6a7534..3b27693b 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -79,6 +79,28 @@ court_specific_requirements: When a filing is rejected by a court clerk, LITEFile automatically surfaces the clerk's phone number and email address directly on the filer's status screen so they know who to call for assistance. ::: +### Other-party address rules + +An other party's address is optional by default. Add a `party_address` rule only when a court or filing workflow is known to require it. The rule may be placed under `defaults`, under a case type, or directly under a court in `court_specific_requirements`. More specific layers override the default. + +```yaml +defaults: + party_address: + required: false + required_for_party_types: [] + required_for_filing_types: [] + required_for_services: [] + +court_specific_requirements: + "example:civil": + party_address: + required_for_filing_types: ["SUMMONS"] + required_for_services: ["PERSONAL_SERVICE"] + reason: "The court needs an address to issue or serve these documents." +``` + +Set `required: true` when every other party in that layer needs an address. The three `required_for_*` lists may contain a Tyler code or name and are matched without regard to capitalization. A matching party type, filing type, or selected optional service makes the address required. LITEFile also honors an address-required flag from live Tyler party metadata if the code list provides one. + --- ## 3. Wording that differs by state diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py index 818a0238..fe2c199d 100644 --- a/efile_app/efile/services/efsp_errors.py +++ b/efile_app/efile/services/efsp_errors.py @@ -44,6 +44,7 @@ # "al_court_bundle.elements[0].filing_type" -> document 1, field filing_type _BUNDLE_FIELD = re.compile(r"^al_court_bundle\.elements\[(\d+)\]\.(.+)$") +_OTHER_PARTY_ADDRESS_FIELD = re.compile(r"^other_parties\[(\d+)\]\.address\.(address|city|state|zip)$") _MAX_RAW_BODY = 300 @@ -162,6 +163,7 @@ def _describe_var(var, *, missing: bool) -> str: name = str(var.get("name") or "").strip() if not name: return "" + current = str(var.get("currentVal") or "").strip() match = _BUNDLE_FIELD.match(name) if match: @@ -170,9 +172,15 @@ def _describe_var(var, *, missing: bool) -> str: else: where = "" - label = _FIELD_LABELS.get(name, name.replace("_", " ")) - current = str(var.get("currentVal") or "").strip() + address_match = _OTHER_PARTY_ADDRESS_FIELD.match(name) + if address_match: + index, field = address_match.groups() + field_label = {"address": "street address", "zip": "ZIP code"}.get(field, field) + if current: + return f"{current!r} is not a {field_label} the court accepts for other party {int(index) + 1}" + return f"{field_label} is required for other party {int(index) + 1}'s mailing address" + label = _FIELD_LABELS.get(name, name.replace("_", " ")) if missing or not current: return f"no {label} was given{where}" return f"{current!r} is not a {label} this court accepts{where}" diff --git a/efile_app/efile/services/party_requirements.py b/efile_app/efile/services/party_requirements.py new file mode 100644 index 00000000..b61e667b --- /dev/null +++ b/efile_app/efile/services/party_requirements.py @@ -0,0 +1,118 @@ +"""Determine when the court needs an other party's mailing address.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from efile.utils.config_loader import config_loader + + +@dataclass(frozen=True) +class AddressRequirement: + required: bool = False + reason: str = "" + + +_DEFAULT_REASON = "The court requires a mailing address for this party before the filing can continue." + + +def _values(items) -> set[str]: + return {str(item).strip().casefold() for item in items or [] if str(item).strip()} + + +def _matches(configured, *actual) -> bool: + wanted = _values(configured) + return bool(wanted.intersection(_values(actual))) + + +def _metadata_requires_address(party, party_types) -> bool: + if party is None: + return False + for party_type in party_types or []: + if str(party_type.get("code") or "") != str(party.party_type or ""): + continue + return party_type.get("address_required") is True + return False + + +def party_address_requirement(draft, party=None, *, party_types=None) -> AddressRequirement: + """Resolve live metadata and layered YAML rules for one other party. + + The default is deliberately optional. A state/case/court configuration can + require every address, or only addresses selected by party type, filing + type, or optional-service code. ``get_case_type_config`` has already merged + the base, state, case-type, and court layers before this function reads it. + """ + if draft is None: + return AddressRequirement() + + # The filer's own contact address is collected on a separate screen and is + # always part of the EFSP user record. This resolver changes only the rule + # for other parties. + if party is not None and getattr(party, "role", "") == "filer": + return AddressRequirement(True, _DEFAULT_REASON) + + if _metadata_requires_address(party, party_types): + return AddressRequirement(True, _DEFAULT_REASON) + + jurisdiction = getattr(draft, "jurisdiction", "") + if not jurisdiction: + return AddressRequirement() + case_type = getattr(draft, "case_type_name", "") or getattr(draft, "case_type_code", "") or "" + jurisdiction_config = config_loader.load_jurisdiction_config(jurisdiction) or {} + config = ( + config_loader.get_case_type_config( + jurisdiction, + case_type, + court=getattr(draft, "court_code", ""), + ) + or {} + ) + rule: dict[str, Any] = config_loader._deep_merge( + (jurisdiction_config.get("defaults") or {}).get("party_address") or {}, + config.get("party_address") or {}, + ) + court_config = (jurisdiction_config.get("court_specific_requirements") or {}).get( + getattr(draft, "court_code", ""), + {}, + ) + rule = config_loader._deep_merge(rule, court_config.get("party_address") or {}) + reason = str(rule.get("reason") or _DEFAULT_REASON) + + if rule.get("required") is True: + return AddressRequirement(True, reason) + + if party is not None and _matches( + rule.get("required_for_party_types"), + party.party_type, + party.party_type_name, + ): + return AddressRequirement(True, reason) + + document_manager = getattr(draft, "documents", None) + documents = list(document_manager.all()) if hasattr(document_manager, "all") else [] + if any( + _matches(rule.get("required_for_filing_types"), document.filing_type_code, document.filing_type_name) + for document in documents + ): + return AddressRequirement(True, reason) + + selected_services = {str(code) for document in documents for code in (document.requested_optional_services or [])} + selected_services.update(str(code) for code in (getattr(draft, "optional_services", None) or [])) + if _matches(rule.get("required_for_services"), *selected_services): + return AddressRequirement(True, reason) + + return AddressRequirement() + + +def address_values(party) -> tuple[str, str, str, str]: + return (party.address_line_1, party.city, party.state, party.zip_code) + + +def address_is_blank(party) -> bool: + return not any((*address_values(party), party.address_line_2)) + + +def address_is_complete(party) -> bool: + return all(address_values(party)) diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py index 214ba6fb..d7e835dd 100644 --- a/efile_app/efile/services/people.py +++ b/efile_app/efile/services/people.py @@ -11,6 +11,7 @@ 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.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,14 +25,23 @@ _RESPONDING_PARTY_KEYWORDS = PARTY_SIDE_KEYWORDS[PartySide.RESPONDING] -def party_is_complete(party: FilingParty) -> bool: +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)) - has_address = bool(party.address_line_1 and party.city and party.state and party.zip_code) - return bool(party.party_type and has_name and has_address) + address_required = party_address_requirement( + draft or getattr(party, "draft", None), + party, + party_types=party_types, + ).required + valid_address = address_is_complete(party) or (not address_required and address_is_blank(party)) + return bool(party.party_type and has_name and valid_address) -def incomplete_parties(draft: FilingDraft): - return [party for party in FilingParty.objects.filter(draft=draft) if not party_is_complete(party)] +def incomplete_parties(draft: FilingDraft, *, party_types=None): + return [ + party + for party in FilingParty.objects.filter(draft=draft) + if not party_is_complete(party, draft=draft, party_types=party_types) + ] def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]: @@ -55,6 +65,13 @@ def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]: "code": str(item.get("code") or ""), "name": str(item.get("name") or ""), "required": str(item.get("isrequired", "")).lower() == "true" or item.get("isrequired") is True, + # Tyler does not currently return one of these fields in the + # Illinois staging lists we checked. Preserve support for the live + # metadata rather than forcing a future flag into static YAML. + "address_required": any( + str(item.get(key, "")).lower() == "true" + for key in ("addressrequired", "addressRequired", "partyaddressrequired", "requirespartyaddress") + ), } for item in data if isinstance(item, dict) and item.get("code") and item.get("name") diff --git a/efile_app/efile/static/config/base-case-types.yaml b/efile_app/efile/static/config/base-case-types.yaml index 6d2d33de..d62cdf4d 100644 --- a/efile_app/efile/static/config/base-case-types.yaml +++ b/efile_app/efile/static/config/base-case-types.yaml @@ -29,6 +29,14 @@ jurisdiction: contact_address: "Suffolk University Law School LIT Lab, 120 Tremont Street, Boston, MA" defaults: + # Other-party addresses are optional unless a state/case/court layer, a + # selected filing/service code, or live Tyler metadata says otherwise. + party_address: + required: false + required_for_party_types: [] + required_for_filing_types: [] + required_for_services: [] + reason: "The court requires a mailing address for this party before the filing can continue." sections: parties: title: "Required parties" @@ -61,14 +69,14 @@ defaults: required: true column_width: "col-6" - section_title: "Their physical address" - required: true + required: false conditional_requirements: {} # Default: show for all courts except those explicitly hidden fields: - name: "other_address_line_1" label: "Street Address" type: "text" - required: true + required: false column_width: "col-12" - name: "other_address_line_2" label: "Street Address 2" @@ -78,17 +86,17 @@ defaults: - name: "other_address_city" label: "City" type: "text" - required: true + required: false column_width: "col-6" - name: "other_address_state" label: "State" type: "us_state" - required: true + required: false column_width: "col-6" - name: "other_address_zip" label: "Zip Code" type: "text" - required: true + required: false column_width: "col-6" - section_title: "Their contact information" required: true diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 993cdfb3..b2f3ba36 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -1233,6 +1233,40 @@ font-weight: 500; } +.address-choice { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.85rem; + margin-bottom: 1rem; +} + +.address-choice .form-check { + margin-bottom: 0; +} + +.address-explainer { + background: none; + border: 0; + border-bottom: 1px dotted var(--better-blue); + color: var(--better-blue); + font-size: 0.92rem; + padding: 0; +} + +.address-explainer:hover, +.address-explainer:focus-visible { + border-bottom-style: solid; +} + +.address-explainer-popover { + max-width: 23rem; +} + +.address-explainer-popover .popover-body p:last-child { + margin-bottom: 0; +} + .people-grid { display: grid; gap: 1rem; diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js index f924e002..1b5d8f5a 100644 --- a/efile_app/efile/static/js/filing-payload.js +++ b/efile_app/efile/static/js/filing-payload.js @@ -50,6 +50,14 @@ const FilingPayload = { }, partyFromDraft(party) { + const address = { + address: party.address_line_1 || "", + unit: party.address_line_2 || "", + city: party.city || "", + state: party.state || "", + zip: party.zip_code || "", + country: party.country || "US" + }; return { party_type: party.party_type, name: { @@ -58,14 +66,12 @@ const FilingPayload = { last: party.last_name || "", suffix: party.suffix || "" }, - address: { - address: party.address_line_1 || "", - unit: party.address_line_2 || "", - city: party.city || "", - state: party.state || "", - zip: party.zip_code || "", - country: party.country || "US" - }, + // Tyler validates a present address object, even when all its + // values are blank. Omit it when the optional address is wholly + // blank; otherwise staging rejects the blank state as a bad code. + ...([address.address, address.unit, address.city, address.state, address.zip].some(Boolean) ? { + address + } : {}), email: party.email || "", phone_number: party.phone || "", is_new: !party.external_party_id @@ -152,20 +158,24 @@ const FilingPayload = { .map((party) => this.partyFromDraft(party)); if (other_parties.length === 0 && caseData.other_first_name && caseData.other_party_type) { + const legacyAddress = { + address: caseData.other_address_line_1 || "", + unit: caseData.other_address_line_2 || "", + city: caseData.other_address_city || "", + state: caseData.other_address_state || "", + zip: caseData.other_address_zip || "", + country: "US" + }; other_parties.push({ party_type: caseData.other_party_type, name: { first: caseData.other_first_name, last: caseData.other_last_name }, - address: { - address: caseData.other_address_line_1, - unit: caseData.other_address_line_2, - city: caseData.other_address_city, - state: caseData.other_address_state, - zip: caseData.other_address_zip, - country: "US" - }, + ...([legacyAddress.address, legacyAddress.unit, legacyAddress.city, legacyAddress.state, legacyAddress.zip] + .some(Boolean) ? { + address: legacyAddress + } : {}), email: caseData.other_email, phone_number: caseData.other_phone_number, is_new: true, diff --git a/efile_app/efile/static/js/party-details.js b/efile_app/efile/static/js/party-details.js index 7fa7cbec..76907604 100644 --- a/efile_app/efile/static/js/party-details.js +++ b/efile_app/efile/static/js/party-details.js @@ -3,6 +3,41 @@ if (!form) return; const personFields = form.querySelector(".person-fields"); const organizationFields = form.querySelector(".organization-fields"); + const addressToggle = document.getElementById("add-party-address"); + const addressFields = document.getElementById("party-address-fields"); + const addressExplainer = document.getElementById("address-explainer"); + const addressExplainerContent = document.getElementById("address-explainer-content"); + + function updateAddressFields() { + if (!addressToggle || !addressFields) return; + addressFields.hidden = !addressToggle.checked; + ["address_line_1", "city", "state", "zip_code"].forEach((name) => { + form.elements.namedItem(name).required = addressToggle.checked; + }); + } + + if (addressToggle) { + addressToggle.addEventListener("change", updateAddressFields); + updateAddressFields(); + } + + if (addressExplainer && addressExplainerContent && window.bootstrap) { + const popover = new window.bootstrap.Popover(addressExplainer, { + title: addressExplainer.textContent.trim(), + content: addressExplainerContent.innerHTML, + html: true, + trigger: "click", + placement: "top", + customClass: "address-explainer-popover" + }); + document.addEventListener("click", (event) => { + const inside = addressExplainer.contains(event.target) || event.target.closest(".address-explainer-popover"); + if (!inside) popover.hide(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") popover.hide(); + }); + } // A suffix has to exactly match one of the court's own codes, so it's a // dropdown fed from the court rather than free text. If it can't load diff --git a/efile_app/efile/templates/efile/party_details.html b/efile_app/efile/templates/efile/party_details.html index baafd546..ef7914f8 100644 --- a/efile_app/efile/templates/efile/party_details.html +++ b/efile_app/efile/templates/efile/party_details.html @@ -14,7 +14,9 @@

{% translate "Add the next party" %} {% endif %}

-

{% translate "Enter the court role, name, and mailing address for this party." %}

+

+ {% translate "Enter this party's court role and name. Add a mailing address if you know it or the court requires it." %} +

{% csrf_token %} @@ -102,14 +104,45 @@

{% translate "Organization" %}

-

{% translate "Mailing address" %}

-
+

+ {% translate "Mailing address" %} + {% if not address_required %} + {% translate "Optional" %} + {% endif %} +

+ {% if address_required %} +

{{ address_reason }}

+ {% else %} +
+
+ + +
+ +
+ + {% endif %} +
diff --git a/efile_app/efile/tests/test_efsp_errors.py b/efile_app/efile/tests/test_efsp_errors.py index 04084397..1e4df9c3 100644 --- a/efile_app/efile/tests/test_efsp_errors.py +++ b/efile_app/efile/tests/test_efsp_errors.py @@ -76,6 +76,23 @@ def test_required_var_is_reported_as_missing(): assert "no case type was given" in message +def test_live_other_party_address_error_says_which_party_and_field_to_fix(): + body = { + "wrong_vars": [ + { + "name": "other_parties[0].address.state", + "description": ": no match found", + "currentVal": "", + } + ] + } + + message = describe_efsp_error(FakeResponse(400, body)) + + assert "state is required" in message + assert "other party 1" in message + + def test_several_problems_are_all_reported(): body = { "wrong_vars": [ diff --git a/efile_app/efile/tests/test_extracted_parties.py b/efile_app/efile/tests/test_extracted_parties.py index a5635b22..5971a678 100644 --- a/efile_app/efile/tests/test_extracted_parties.py +++ b/efile_app/efile/tests/test_extracted_parties.py @@ -383,9 +383,9 @@ def test_the_party_screen_maps_sides_and_asks_only_for_what_is_missing(client, r responding.refresh_from_db() # The extracted defendant already covers the required defendant party type, - # so no blank placeholder is created alongside them -- the filer is sent to - # finish the person the document actually named. + # so no blank placeholder is created alongside them. Their name and mapped + # role are enough when this filing has no rule requiring an address. assert responding.party_type == "defendant" assert FilingParty.objects.filter(draft=review_draft, role="other").count() == 1 assert response.status_code == 302 - assert response.url.endswith(f"?party={responding.pk}") + assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) diff --git a/efile_app/efile/tests/test_party_address_requirements.py b/efile_app/efile/tests/test_party_address_requirements.py new file mode 100644 index 00000000..84128ca0 --- /dev/null +++ b/efile_app/efile/tests/test_party_address_requirements.py @@ -0,0 +1,89 @@ +from unittest.mock import patch + +import pytest + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.party_requirements import party_address_requirement +from efile.services.people import party_is_complete + + +@pytest.fixture +def draft(django_user_model): + user = django_user_model.objects.create_user(username="address-rules") + return FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + court_code="court-1", + case_type_code="case-1", + case_type_name="Example case", + ) + + +@pytest.mark.django_db +def test_blank_address_is_complete_by_default_but_partial_address_is_not(draft): + party = FilingParty.objects.create( + draft=draft, + role="other", + party_type="DEF", + first_name="Morgan", + last_name="Lee", + ) + + assert party_is_complete(party) + party.state = "IL" + assert not party_is_complete(party) + + +@pytest.mark.django_db +def test_live_party_metadata_can_require_the_address(draft): + party = FilingParty.objects.create( + draft=draft, + role="other", + party_type="DEF", + first_name="Morgan", + last_name="Lee", + ) + + requirement = party_address_requirement( + draft, + party, + party_types=[{"code": "DEF", "address_required": True}], + ) + + assert requirement.required + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("rule", "document_values"), + [ + ({"required_for_party_types": ["DEF"]}, {}), + ({"required_for_filing_types": ["FILE-1"]}, {"filing_type_code": "FILE-1"}), + ({"required_for_services": ["SERVICE-1"]}, {"requested_optional_services": ["SERVICE-1"]}), + ], +) +def test_layered_config_can_require_address_by_party_filing_or_service(draft, rule, document_values): + party = FilingParty.objects.create( + draft=draft, + role="other", + party_type="DEF", + first_name="Morgan", + last_name="Lee", + ) + if document_values: + FilingDocument.objects.create(draft=draft, role=FilingDocument.Role.LEAD, **document_values) + + with ( + patch( + "efile.services.party_requirements.config_loader.load_jurisdiction_config", + return_value={"defaults": {"party_address": {"required": False}}}, + ), + patch( + "efile.services.party_requirements.config_loader.get_case_type_config", + return_value={"party_address": {**rule, "reason": "This filing needs an address."}}, + ), + ): + requirement = party_address_requirement(draft, party) + + assert requirement.required + assert requirement.reason == "This filing needs an address." diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py index 8f890c11..4e79efca 100644 --- a/efile_app/efile/tests/test_people_flow.py +++ b/efile_app/efile/tests/test_people_flow.py @@ -7,6 +7,7 @@ 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.party_requirements import AddressRequirement from efile.services.people import guess_filer_party_type from efile.workflow import ExistingCase, WorkflowStepKey @@ -286,6 +287,144 @@ def test_party_details_saves_party_and_advances_to_payment_when_no_questions(cli assert people_draft.current_step == WorkflowStepKey.PAYMENT +@pytest.mark.django_db +def test_party_details_saves_an_other_party_without_an_optional_address(client, people_draft): + FilingParty.objects.create( + draft=people_draft, + role="filer", + sort_order=0, + party_type="plaintiff", + first_name="Jamie", + last_name="Rivera", + address_line_1="100 State Street", + city="Chicago", + state="IL", + zip_code="60601", + ) + party = FilingParty.objects.create( + draft=people_draft, + role="other", + sort_order=0, + party_type="defendant", + party_type_name="Defendant", + ) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + response = client.post( + f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}", + { + "party_kind": "person", + "party_type": "defendant", + "first_name": "Morgan", + "last_name": "Lee", + }, + ) + + party.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert party.first_name == "Morgan" + assert party.address_line_1 == "" + + +@pytest.mark.django_db +def test_party_details_does_not_discard_a_saved_optional_address(client, people_draft): + party = FilingParty.objects.create( + draft=people_draft, + role="other", + sort_order=0, + party_type="defendant", + party_type_name="Defendant", + first_name="Morgan", + last_name="Lee", + address_line_1="200 Court Avenue", + city="Chicago", + state="IL", + zip_code="60602", + ) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + response = client.get(f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}") + + content = response.content.decode() + assert 'value="200 Court Avenue"' in content + assert 'value="60602"' in content + address_toggle = re.search(r']*id="add-party-address"[^>]*>', content) + assert address_toggle is not None + assert "checked" in address_toggle.group() + address_fields = re.search(r']*id="party-address-fields"[^>]*>', content) + assert address_fields is not None + assert "hidden" not in address_fields.group() + + +@pytest.mark.django_db +def test_optional_address_fields_start_hidden_with_checkbox_and_help(client, people_draft): + party = FilingParty.objects.create( + draft=people_draft, + role="other", + sort_order=0, + party_type="defendant", + party_type_name="Defendant", + first_name="Morgan", + last_name="Lee", + ) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + response = client.get(f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}") + + content = response.content.decode() + address_toggle = re.search(r']*id="add-party-address"[^>]*>', content) + assert address_toggle is not None + assert "checked" not in address_toggle.group() + address_fields = re.search(r']*id="party-address-fields"[^>]*>', content) + assert address_fields is not None + assert "hidden" in address_fields.group() + assert "Do I need to list an address?" in content + assert "Many filings do not require you to list an address for the opposing party" in content + + +@pytest.mark.django_db +def test_party_details_rejects_a_partial_optional_address(client, people_draft): + party = FilingParty.objects.create(draft=people_draft, role="other", sort_order=0) + + with patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES): + response = client.post( + f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}", + { + "party_kind": "person", + "party_type": "defendant", + "first_name": "Morgan", + "last_name": "Lee", + "state": "IL", + }, + ) + + assert response.status_code == 200 + assert b"Complete the optional mailing address" in response.content + + +@pytest.mark.django_db +def test_party_details_explains_a_configured_required_address(client, people_draft): + party = FilingParty.objects.create(draft=people_draft, role="other", sort_order=0) + requirement = AddressRequirement(True, "The court needs this address for service.") + + with ( + patch("efile.views.party_details.get_party_types", return_value=PARTY_TYPES), + patch("efile.views.party_details.party_address_requirement", return_value=requirement), + ): + response = client.get(f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}") + + content = response.content.decode() + assert "The court needs this address for service." in content + street = re.search(r']*name="address_line_1"[^>]*>', content) + assert street is not None + assert "required" in street.group() + assert 'id="add-party-address"' not in content + address_fields = re.search(r']*id="party-address-fields"[^>]*>', content) + assert address_fields is not None + assert "hidden" not in address_fields.group() + + @pytest.mark.django_db def test_party_details_returns_to_review_when_edited_from_there(client, people_draft): FilingParty.objects.create( diff --git a/efile_app/efile/tests/test_workflow.py b/efile_app/efile/tests/test_workflow.py index ab721624..3bd3dc73 100644 --- a/efile_app/efile/tests/test_workflow.py +++ b/efile_app/efile/tests/test_workflow.py @@ -27,6 +27,8 @@ def draft(**overrides): "existing_case": ExistingCase.NEW, "case_questions_required": False, "parties": [], + "court_code": "", + "case_type_code": "", } values.update(overrides) return SimpleNamespace(**values) @@ -137,6 +139,32 @@ def test_party_details_only_appear_for_incomplete_parties(): assert WorkflowStepKey.PARTY_DETAILS not in keys(get_visible_workflow(draft(parties=[complete]))) +def test_party_details_appear_when_live_party_metadata_requires_address(monkeypatch): + party = SimpleNamespace( + party_type="DEF", + first_name="Morgan", + last_name="Lee", + organization_name="", + address_line_1="", + city="", + state="", + zip_code="", + address_line_2="", + ) + current_draft = draft( + parties=[party], + jurisdiction="illinois", + court_code="court-1", + case_type_code="case-1", + ) + monkeypatch.setattr( + "efile.services.people.get_party_types", + lambda _draft: [{"code": "DEF", "name": "Defendant", "address_required": True}], + ) + + assert WorkflowStepKey.PARTY_DETAILS in keys(get_visible_workflow(current_draft)) + + def test_case_questions_only_appear_when_required(): assert WorkflowStepKey.CASE_QUESTIONS not in keys(get_visible_workflow(draft())) assert WorkflowStepKey.CASE_QUESTIONS in keys(get_visible_workflow(draft(case_questions_required=True))) diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py index debef8f1..29dc1a8f 100644 --- a/efile_app/efile/views/parties.py +++ b/efile_app/efile/views/parties.py @@ -84,7 +84,7 @@ def parties(request, jurisdiction): 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) + incomplete = incomplete_parties(draft, party_types=party_types) if incomplete: draft.current_step = WorkflowStepKey.PARTY_DETAILS draft.save(update_fields=["current_step", "updated_at"]) @@ -103,7 +103,8 @@ def parties(request, jurisdiction): return redirect(get_step_url(draft.current_step, jurisdiction)) roster = [ - {"party": party, "complete": party_is_complete(party)} for party in FilingParty.objects.filter(draft=draft) + {"party": party, "complete": party_is_complete(party, party_types=party_types)} + for party in FilingParty.objects.filter(draft=draft) ] guessed_party_type = None if filer.party_type else guess_filer_party_type(draft, party_types) context = { diff --git a/efile_app/efile/views/party_details.py b/efile_app/efile/views/party_details.py index 6a71b7d5..447616b5 100644 --- a/efile_app/efile/views/party_details.py +++ b/efile_app/efile/views/party_details.py @@ -8,7 +8,13 @@ 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.people import get_case_questions, get_party_types, incomplete_parties, needs_amount_in_controversy +from efile.services.party_requirements import address_is_blank, party_address_requirement +from efile.services.people import ( + get_case_questions, + get_party_types, + incomplete_parties, + needs_amount_in_controversy, +) from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to @@ -26,6 +32,7 @@ def party_details(request, jurisdiction): party = get_object_or_404(FilingParty, draft=draft, role="other", pk=request.GET.get("party")) 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) if request.method == "POST": party_kind = request.POST.get("party_kind", "person") @@ -35,15 +42,36 @@ def party_details(request, jurisdiction): first_name = request.POST.get("first_name", "").strip() last_name = request.POST.get("last_name", "").strip() organization_name = request.POST.get("organization_name", "").strip() - required_address = { + address = { "address_line_1": request.POST.get("address_line_1", "").strip(), "city": request.POST.get("city", "").strip(), "state": request.POST.get("state", "").strip(), "zip_code": request.POST.get("zip_code", "").strip(), } + address_line_2 = request.POST.get("address_line_2", "").strip() + selected_party = FilingParty( + draft=draft, + party_type=party_type, + party_type_name=party_type_names.get(party_type, ""), + ) + address_requirement = party_address_requirement(draft, selected_party, party_types=party_types) + address_started = any(address.values()) or bool(address_line_2) + address_complete = all(address.values()) + show_optional_address = address_started has_name = organization_name if party_kind == "organization" else first_name and last_name - if not party_type or not has_name or not all(required_address.values()): - messages.error(request, "Complete the party role, name, and mailing address.") + if not party_type or not has_name: + messages.error(request, "Complete the party role and name.") + elif (address_requirement.required or address_started) and not address_complete: + # Keep the attempted address visible when returning validation + # errors. These assignments only affect this rendered instance; + # nothing is saved until every required part is present. + for field, value in address.items(): + setattr(party, field, value) + party.address_line_2 = address_line_2 + if address_requirement.required: + messages.error(request, f"Complete the mailing address. {address_requirement.reason}") + else: + messages.error(request, "Complete the optional mailing address, or clear all of its fields.") else: party.party_type = party_type party.party_type_name = party_type_names.get(party_type, party.party_type_name) @@ -56,15 +84,15 @@ def party_details(request, jurisdiction): party.middle_name = request.POST.get("middle_name", "").strip() if party_kind == "person" else "" party.last_name = last_name if party_kind == "person" else "" party.suffix = request.POST.get("suffix", "").strip() if party_kind == "person" else "" - for field, value in required_address.items(): + for field, value in address.items(): setattr(party, field, value) - party.address_line_2 = request.POST.get("address_line_2", "").strip() + party.address_line_2 = address_line_2 party.email = request.POST.get("email", "").strip() party.phone = request.POST.get("phone", "").strip() party.save() return_to = request.POST.get("return_to") - remaining = [item for item in incomplete_parties(draft) if item.pk != party.pk] + remaining = [item for item in incomplete_parties(draft, party_types=party_types) if item.pk != party.pk] if remaining: url = reverse("party_details", kwargs={"jurisdiction": jurisdiction}) return redirect(with_return_to(f"{url}?party={remaining[0].pk}", return_to)) @@ -81,6 +109,7 @@ def party_details(request, jurisdiction): draft.save(update_fields=["supplemental_fields", "current_step", "updated_at"]) return redirect(get_step_url(draft.current_step, jurisdiction)) + address_requirement = party_address_requirement(draft, party, party_types=party_types) context = { "is_logged_in": True, "filing_draft": draft_snapshot(draft), @@ -89,6 +118,9 @@ def party_details(request, jurisdiction): "party_kind": "organization" if party.organization_name else "person", "return_to": request.GET.get("return_to", ""), "court_code": draft.court_code, + "address_required": address_requirement.required, + "address_reason": address_requirement.reason, + "show_optional_address": show_optional_address, } context.update(get_workflow_context(WorkflowStepKey.PARTY_DETAILS, jurisdiction, draft)) return render(request, "efile/party_details.html", context) diff --git a/efile_app/efile/workflow.py b/efile_app/efile/workflow.py index 5d734c76..ba837afa 100644 --- a/efile_app/efile/workflow.py +++ b/efile_app/efile/workflow.py @@ -13,7 +13,6 @@ from enum import StrEnum from typing import Any -from django.db.models import Q from django.urls import reverse @@ -190,31 +189,19 @@ def _has_incomplete_parties(draft: Any | None) -> bool: parties = getattr(draft, "parties", None) if parties is None: return bool(_draft_value(draft, "has_incomplete_parties", False)) + from efile.services.people import get_party_types, party_is_complete + + party_types = [] + if _draft_value(draft, "court_code") and _draft_value(draft, "case_type_code"): + party_types = get_party_types(draft) if hasattr(parties, "filter"): - incomplete = ( - Q(party_type="") - | (Q(organization_name="") & (Q(first_name="") | Q(last_name=""))) - | Q(address_line_1="") - | Q(city="") - | Q(state="") - | Q(zip_code="") - ) - return parties.filter(incomplete).exists() + return any(not party_is_complete(party, draft=draft, party_types=party_types) for party in parties.all()) try: party_list = list(parties.all()) except (AttributeError, TypeError): party_list = list(parties) - return any( - not ( - party.party_type - and (party.organization_name or (party.first_name and party.last_name)) - and party.address_line_1 - and party.city - and party.state - and party.zip_code - ) - for party in party_list - ) + + return any(not party_is_complete(party, draft=draft, party_types=party_types) for party in party_list) def _has_case_questions(draft: Any | None) -> bool: diff --git a/efile_app/js-tests/filing-payload.test.js b/efile_app/js-tests/filing-payload.test.js index de66f9d6..0761523e 100644 --- a/efile_app/js-tests/filing-payload.test.js +++ b/efile_app/js-tests/filing-payload.test.js @@ -295,6 +295,33 @@ test("durable non-filer parties are included without collapsing to one legacy pa assert.strictEqual(result.other_parties.length, 2); assert.strictEqual(result.other_parties[0].name.first, "Alex"); assert.strictEqual(result.other_parties[1].name.first, "Example LLC"); + assert.strictEqual("address" in result.other_parties[0], false); + assert.strictEqual("address" in result.other_parties[1], false); +}); + +test("a saved optional other-party address remains in the filing payload", () => { + const handler = makeHandler(); + const party = { + party_type: "DEF", + first_name: "Alex", + last_name: "Morgan", + address_line_1: "10 State Street", + address_line_2: "Unit 2", + city: "Chicago", + state: "IL", + zip_code: "60601" + }; + + const result = handler.partyFromDraft(party); + + assert.deepStrictEqual(result.address, { + address: "10 State Street", + unit: "Unit 2", + city: "Chicago", + state: "IL", + zip: "60601", + country: "US" + }); }); test("optional services for lead and supporting documents are included in bundles", () => {