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