diff --git a/docs/screenshots/reorganized-flow/08-your-information.png b/docs/screenshots/reorganized-flow/08-your-information.png new file mode 100644 index 0000000..759bf7e Binary files /dev/null and b/docs/screenshots/reorganized-flow/08-your-information.png differ diff --git a/docs/screenshots/reorganized-flow/09-parties.png b/docs/screenshots/reorganized-flow/09-parties.png new file mode 100644 index 0000000..0a9249b Binary files /dev/null and b/docs/screenshots/reorganized-flow/09-parties.png differ diff --git a/docs/screenshots/reorganized-flow/10-party-details.png b/docs/screenshots/reorganized-flow/10-party-details.png new file mode 100644 index 0000000..9c59bfe Binary files /dev/null and b/docs/screenshots/reorganized-flow/10-party-details.png differ diff --git a/docs/screenshots/reorganized-flow/11-case-questions.png b/docs/screenshots/reorganized-flow/11-case-questions.png new file mode 100644 index 0000000..936536a Binary files /dev/null and b/docs/screenshots/reorganized-flow/11-case-questions.png differ diff --git a/efile_app/efile/migrations/0006_filingparty_party_type_name.py b/efile_app/efile/migrations/0006_filingparty_party_type_name.py new file mode 100644 index 0000000..bf8d70c --- /dev/null +++ b/efile_app/efile/migrations/0006_filingparty_party_type_name.py @@ -0,0 +1,14 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("efile", "0005_document_checklist_state")] + + operations = [ + migrations.AddField( + model_name="filingparty", + name="party_type_name", + field=models.CharField(blank=True, default="", max_length=255), + preserve_default=False, + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 7969168..0cbc9e8 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -185,6 +185,7 @@ class FilingParty(models.Model): sort_order = models.PositiveIntegerField(default=0) party_type = models.CharField(max_length=100, blank=True) + party_type_name = models.CharField(max_length=255, blank=True) external_party_id = models.CharField(max_length=255, blank=True) first_name = models.CharField(max_length=100, blank=True) diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index e03ffe2..0840342 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -278,6 +278,16 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]: _put(data, "reason_for_change", draft.name_change_reason) for party in FilingParty.objects.filter(draft=draft): + if party.role == "filer": + _put(data, "petitioner_first_name", party.first_name) + _put(data, "petitioner_last_name", party.last_name) + _put(data, "petitioner_address", party.address_line_1) + _put(data, "petitioner_email", party.email) + _put(data, "petitioner_phone", party.phone) + if party.party_type: + for key in _PETITIONER_PARTY_TYPE_KEYS: + _put(data, key, party.party_type) + continue spec = _PARTY_SPECS.get(party.role) if spec is None: continue @@ -287,6 +297,30 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]: for key in _PETITIONER_PARTY_TYPE_KEYS: _put(data, key, party.party_type) + filing_parties = [ + { + "id": party.pk, + "role": party.role, + "party_type": party.party_type, + "party_type_name": party.party_type_name, + "first_name": party.first_name, + "middle_name": party.middle_name, + "last_name": party.last_name, + "suffix": party.suffix, + "organization_name": party.organization_name, + "email": party.email, + "phone": party.phone, + "address_line_1": party.address_line_1, + "address_line_2": party.address_line_2, + "city": party.city, + "state": party.state, + "zip_code": party.zip_code, + "country": party.country, + } + for party in FilingParty.objects.filter(draft=draft) + ] + _put(data, "filing_parties", filing_parties) + # Supplemental answers are emitted as stored (a False/0 answer is meaningful). for key, value in (draft.supplemental_fields or {}).items(): data[key] = value diff --git a/efile_app/efile/services/people.py b/efile_app/efile/services/people.py new file mode 100644 index 0000000..61ce532 --- /dev/null +++ b/efile_app/efile/services/people.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import logging +from typing import Any + +import requests +from django.conf import settings + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase + +logger = logging.getLogger(__name__) + +# Keywords matched against a court's own party-type names (e.g. +# "Plaintiff/Petitioner") to turn a case-posture guess into one of that +# court's actual codes. +_INITIATING_PARTY_KEYWORDS = ("plaintiff", "petitioner") +_RESPONDING_PARTY_KEYWORDS = ("defendant", "respondent") + + +def party_is_complete(party: FilingParty) -> 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) + + +def incomplete_parties(draft: FilingDraft): + return [party for party in FilingParty.objects.filter(draft=draft) if not party_is_complete(party)] + + +def get_party_types(draft: FilingDraft) -> list[dict[str, Any]]: + if not draft.court_code or not draft.case_type_code: + return [] + url = ( + f"{settings.EFSP_URL}/jurisdictions/{draft.jurisdiction}/codes/courts/" + f"{draft.court_code}/case_types/{draft.case_type_code}/party_types" + ) + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + data = response.json() + except (requests.RequestException, ValueError): + logger.warning("Could not load party types for draft %s", draft.pk) + return [] + if not isinstance(data, list): + return [] + return [ + { + "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, + } + for item in data + if isinstance(item, dict) and item.get("code") and item.get("name") + ] + + +def guess_filer_party_type(draft: FilingDraft, party_types: list[dict[str, Any]]) -> dict[str, Any] | None: + """Suggest the filer's role from case posture alone -- never authoritative. + + A brand new case is almost always opened by the plaintiff/petitioner; an + "Answer" is almost always filed by the defendant/respondent. Callers must + treat this as a one-click suggestion, never pre-fill it: it's a heuristic + that can be wrong (e.g. a co-plaintiff answering on their own claim), and + silently pre-selecting a party's legal role is the kind of mistake a filer + might not think to double check. + """ + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + filing_type_name = (lead.filing_type_name if lead else "") or "" + + if "answer" in filing_type_name.lower(): + keywords = _RESPONDING_PARTY_KEYWORDS + elif draft.existing_case == ExistingCase.NEW: + keywords = _INITIATING_PARTY_KEYWORDS + else: + return None + + for party_type in party_types: + name = party_type["name"].lower() + if any(keyword in name for keyword in keywords): + return party_type + return None + + +def ensure_required_parties(draft: FilingDraft, party_types: list[dict[str, Any]]) -> None: + parties = FilingParty.objects.filter(draft=draft) + covered = set(parties.exclude(party_type="").values_list("party_type", flat=True)) + last_order = parties.filter(role="other").order_by("-sort_order").values_list("sort_order", flat=True).first() + next_order = 0 if last_order is None else last_order + 1 + for party_type in party_types: + code = party_type["code"] + if not party_type["required"] or code in covered: + continue + FilingParty.objects.create( + draft=draft, + role="other", + sort_order=next_order, + party_type=code, + party_type_name=party_type["name"], + ) + covered.add(code) + next_order += 1 + + +def needs_amount_in_controversy(draft: FilingDraft) -> bool: + """True if any organized document's filing type requires a dollar amount. + + Tyler flags this per filing type (FilingCode.amountincontroversy == + "Required"), recorded onto FilingDocument when the filer picks it in + organize_documents. The EFSP rejects the whole filing without it. + """ + return FilingDocument.objects.filter(draft=draft, filing_requires_amount_in_controversy=True).exists() + + +def get_case_questions(draft: FilingDraft) -> list[dict[str, Any]]: + case_type = draft.case_type_name or draft.case_type_code + lowered = case_type.lower() + if "dissolution" in lowered or "divorce" in lowered: + case_type = "dissolution" + config = config_loader.get_case_type_config( + draft.jurisdiction, + case_type, + court=draft.court_code, + ) + questions: list[dict[str, Any]] = [] + for section in (config or {}).get("sections", {}).values(): + for group in section.get("fields", []): + for field in group.get("fields", []): + name = field.get("name", "") + if name not in {"has_children", "child_count"}: + continue + questions.append( + { + "name": name, + "label": field.get("label") or name.replace("_", " ").title(), + "type": field.get("type", "text"), + "required": bool(field.get("required")), + "options": field.get("options", []), + "min": field.get("min"), + "max": field.get("max"), + "group": group.get("section_title", "Case details"), + } + ) + return questions + + +def parse_question_answer(question: dict[str, Any], value: Any) -> Any: + if question["type"] == "radio": + if str(value).lower() == "true": + return True + if str(value).lower() == "false": + return False + if question["type"] == "number" and value not in (None, ""): + return int(value) + return str(value or "").strip() diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 82c0f9d..61e5d9f 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -800,6 +800,188 @@ padding: 0.6rem 0.75rem; } +.compact-choice-list--horizontal { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.people-section { + border-top: 1px solid #e3e8ef; + margin-top: 1.5rem; + padding-top: 1.25rem; +} + +.people-section h2, +.party-roster h2 { + color: #263c58; + font-size: 1.05rem; + margin-bottom: 1rem; +} + +.people-section h2 em { + color: #778397; + font-size: 0.8rem; + font-style: normal; + font-weight: 500; +} + +.people-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.people-grid--name { + grid-template-columns: 1fr 1fr 1fr 0.55fr; +} + +.people-grid__wide { + grid-column: span 2; +} + +.your-role-card { + align-items: end; + background: #f6f9fd; + border: 1px solid #dce3ec; + border-radius: 12px; + display: grid; + gap: 1rem; + grid-template-columns: 0.8fr 1.5fr auto; + margin-top: 1.5rem; + padding: 1.1rem; +} + +.your-role-card>div, +.party-row { + align-items: center; + display: flex; + gap: 0.8rem; +} + +.your-role-card small, +.party-row small { + color: #6d798d; + display: block; +} + +.party-type-hint { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1rem; + margin-bottom: 1rem; +} + +.party-type-hint button { + flex-shrink: 0; + margin-left: auto; +} + +.party-avatar { + align-items: center; + background: #dfeaff; + border-radius: 50%; + color: #285ea8; + display: flex; + flex: 0 0 auto; + height: 44px; + justify-content: center; + width: 44px; +} + +.party-avatar--small { + height: 34px; + width: 34px; +} + +.party-roster { + margin-top: 2rem; +} + +.party-roster__heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.party-roster__heading h2 { + margin: 0; +} + +.party-row { + border: 1px solid #dce3ec; + border-radius: 10px; + display: grid; + grid-template-columns: auto 1fr auto auto auto; + margin-top: 0.75rem; + padding: 0.85rem 1rem; +} + +.party-kind { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 1.5rem; +} + +.party-kind legend { + color: #263c58; + font-size: 0.9rem; + font-weight: 700; + grid-column: 1 / -1; + margin-bottom: 0.5rem; + width: 100%; +} + +.party-kind label { + background: #f6f9fd; + border: 1px solid #dce3ec; + border-radius: 8px; + padding: 0.7rem 1rem; +} + +.question-list { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.question-card { + border: 1px solid #dce3ec; + border-radius: 12px; + padding: 1.1rem; +} + +.question-card legend { + color: #263c58; + float: none; + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.8rem; + width: auto; +} + +.question-options { + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.question-options label { + align-items: center; + background: #f6f9fd; + border: 1px solid #dce3ec; + border-radius: 8px; + display: flex; + gap: 0.7rem; + padding: 0.75rem; +} + +.question-card__hint { + color: #758196; + font-size: 0.88rem; + margin: -0.3rem 0 0.8rem; +} + @media (max-width: 700px) { .review-grid { grid-template-columns: 1fr; @@ -837,4 +1019,18 @@ .organize-card__fields { grid-template-columns: 1fr; } + + .people-grid, + .people-grid--name, + .your-role-card { + grid-template-columns: 1fr; + } + + .people-grid__wide { + grid-column: auto; + } + + .party-row { + grid-template-columns: auto 1fr auto; + } } \ No newline at end of file diff --git a/efile_app/efile/static/js/case-questions.js b/efile_app/efile/static/js/case-questions.js new file mode 100644 index 0000000..295c553 --- /dev/null +++ b/efile_app/efile/static/js/case-questions.js @@ -0,0 +1,19 @@ +(function() { + const form = document.getElementById("case-questions-form"); + if (!form) return; + const children = form.querySelectorAll('input[name="has_children"]'); + const childCountCard = form.querySelector('[data-question="child_count"]'); + const childCount = form.elements.namedItem("child_count"); + if (!children.length || !childCountCard || !childCount) return; + + function updateChildCount() { + const selected = form.querySelector('input[name="has_children"]:checked'); + const show = selected?.value === "true"; + childCountCard.hidden = !show; + childCount.required = show; + if (!show) childCount.value = ""; + } + + children.forEach((radio) => radio.addEventListener("change", updateChildCount)); + updateChildCount(); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/parties.js b/efile_app/efile/static/js/parties.js new file mode 100644 index 0000000..2e3f1df --- /dev/null +++ b/efile_app/efile/static/js/parties.js @@ -0,0 +1,17 @@ +(function() { + const button = document.getElementById("apply-party-type-guess"); + if (!button) return; + + button.addEventListener("click", () => { + const radio = document.querySelector(`input[name="filer_party_type"][value="${button.dataset.value}"]`); + if (radio) { + radio.checked = true; + radio.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + } + const hint = document.getElementById("party-type-hint"); + if (hint) hint.hidden = true; + }); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/party-details.js b/efile_app/efile/static/js/party-details.js new file mode 100644 index 0000000..7fa7cbe --- /dev/null +++ b/efile_app/efile/static/js/party-details.js @@ -0,0 +1,54 @@ +(function() { + const form = document.getElementById("party-details-form"); + if (!form) return; + const personFields = form.querySelector(".person-fields"); + const organizationFields = form.querySelector(".organization-fields"); + + // 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 + // (no court yet, network error), fall back to just the saved value so + // nothing already on this party gets silently dropped. + const suffixSelect = document.getElementById("suffix"); + if (suffixSelect) { + const court = form.dataset.court; + const selected = suffixSelect.dataset.selected || ""; + if (court) { + fetch(`/api/dropdowns/name-suffixes/?${new URLSearchParams({ + jurisdiction: apiUtils.getCurrentJurisdiction(), + court + })}`, { + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + } + }) + .then((response) => response.json()) + .then((result) => { + if (!result.success) return; + (result.data || []).forEach((option) => { + suffixSelect.add(new Option(option.text, option.value)); + }); + if (selected && !suffixSelect.querySelector(`option[value="${selected}"]`)) { + suffixSelect.add(new Option(selected, selected)); + } + suffixSelect.value = selected; + }) + .catch(() => {}); + } else if (selected) { + suffixSelect.add(new Option(selected, selected)); + suffixSelect.value = selected; + } + } + + function updateKind() { + const kind = form.elements.namedItem("party_kind").value; + const isOrganization = kind === "organization"; + personFields.hidden = isOrganization; + organizationFields.hidden = !isOrganization; + form.elements.namedItem("first_name").required = !isOrganization; + form.elements.namedItem("last_name").required = !isOrganization; + form.elements.namedItem("organization_name").required = isOrganization; + } + + form.querySelectorAll('input[name="party_kind"]').forEach((radio) => radio.addEventListener("change", updateKind)); + updateKind(); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/your-information.js b/efile_app/efile/static/js/your-information.js new file mode 100644 index 0000000..221e515 --- /dev/null +++ b/efile_app/efile/static/js/your-information.js @@ -0,0 +1,68 @@ +(function() { + const form = document.getElementById("your-information-form"); + if (!form) return; + + // 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 + // (no court yet, network error), fall back to just the saved value so + // nothing already on the draft gets silently dropped. + const suffixSelect = document.getElementById("suffix"); + if (suffixSelect) { + const court = form.dataset.court; + const selected = suffixSelect.dataset.selected || ""; + if (court) { + fetch(`/api/dropdowns/name-suffixes/?${new URLSearchParams({ + jurisdiction: apiUtils.getCurrentJurisdiction(), + court + })}`, { + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + } + }) + .then((response) => response.json()) + .then((result) => { + if (!result.success) return; + (result.data || []).forEach((option) => { + suffixSelect.add(new Option(option.text, option.value)); + }); + if (selected && !suffixSelect.querySelector(`option[value="${selected}"]`)) { + suffixSelect.add(new Option(selected, selected)); + } + suffixSelect.value = selected; + }) + .catch(() => {}); + } else if (selected) { + suffixSelect.add(new Option(selected, selected)); + suffixSelect.value = selected; + } + } + + const fieldMap = { + first_name: "first_name", + last_name: "last_name", + address_line_1: "address", + address_line_2: "address_line2", + city: "city", + state: "state", + zip_code: "zip", + email: "email", + phone: "phone", + }; + const params = new URLSearchParams({ + jurisdiction: apiUtils.getCurrentJurisdiction() + }); + fetch(`/api/auth/profile/?${params}`, { + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + } + }) + .then((response) => response.json()) + .then((result) => { + if (!result.success || !result.data) return; + Object.entries(fieldMap).forEach(([fieldName, profileKey]) => { + const input = form.elements.namedItem(fieldName); + if (input && !input.value) input.value = result.data[profileKey] || ""; + }); + }) + .catch(() => {}); +})(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/case_questions.html b/efile_app/efile/templates/efile/case_questions.html new file mode 100644 index 0000000..02ad180 --- /dev/null +++ b/efile_app/efile/templates/efile/case_questions.html @@ -0,0 +1,93 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Questions about your case" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "People" %}
+

{% translate "A few questions about your case" %}

+

{% translate "The court needs these answers for the case type you selected." %}

+
+ {% csrf_token %} + +
+ {% for question in questions %} +
+ + {{ question.label }} + {% if question.required %}{% endif %} + + {% if question.type == "radio" %} +
+ {% for option in question.options %} + + {% endfor %} +
+ {% elif question.type == "number" %} + + {% else %} + + {% endif %} +
+ {% endfor %} + {% if show_amount_field %} +
+ + {% translate "Amount in controversy" %} + +

+ {% translate "The dollar amount at stake in this case. The court requires this for the filing type you selected." %} +

+
+ $ + +
+
+ {% endif %} +
+
+ {% if return_to == "review" %} + {% translate "Back to review" %} + + {% else %} + {% translate "Back" %} + + {% endif %} +
+
+
+{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/parties.html b/efile_app/efile/templates/efile/parties.html new file mode 100644 index 0000000..62c7925 --- /dev/null +++ b/efile_app/efile/templates/efile/parties.html @@ -0,0 +1,133 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "People in this filing" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "People" %}
+

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

+

+ {% translate "Start with your role. We will ask for the details of any other required people one at a time." %} +

+
+ {% csrf_token %} + + +
+ +
+ {{ filer.first_name }} {{ filer.last_name }} + {% translate "You โ€” the person filing" %} +
+
+
+ {% translate "What is your role in this case?" %} + {% if guessed_party_type %} +
+ + {% blocktranslate with name=guessed_party_type.name %}Based on this filing, you're likely the {{ name }}. We're not certain, so pick below to confirm.{% endblocktranslate %} + +
+ {% endif %} +
+ {% for party_type in party_types %} + + {% endfor %} +
+
+ {% if not party_types %} +

+ {% translate "Court party roles could not be loaded. Refresh this page before continuing." %} +

+ {% endif %} + +
+
+
+

{% translate "Party list" %}

+
+ {% csrf_token %} + + + +
+
+ {% for item in roster %} +
+ + + + {% if item.party.organization_name %} + {{ item.party.organization_name }} + {% else %} + {{ item.party.first_name }} {{ item.party.last_name }} + {% endif %} + + {{ item.party.party_type_name|default:"Role not chosen" }} + {% if item.party.role == "filer" %} + ยท {% translate "You" %} + {% endif %} + + + + {% if item.complete %} + {% translate "Complete" %} + {% else %} + {% translate "Needs details" %} + {% endif %} + + {% if item.party.role == "other" %} + {% translate "Edit" %} +
+ {% csrf_token %} + + + + +
+ {% endif %} +
+ {% endfor %} +
+
+ {% if return_to == "review" %} + {% translate "Back to review" %} + {% else %} + {% translate "Back" %} + {% endif %} +
+
+{% 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 new file mode 100644 index 0000000..6c28e64 --- /dev/null +++ b/efile_app/efile/templates/efile/party_details.html @@ -0,0 +1,174 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Party details" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "People" %}
+

+ {% if party.first_name or party.organization_name %} + {% translate "Check this party's details" %} + {% else %} + {% translate "Add the next party" %} + {% endif %} +

+

+ {% translate "Enter this person's or organization's court role, name, and mailing address." %} +

+
+ {% csrf_token %} + +
+ {% translate "Role in the case" %} +
+ {% for party_type in party_types %} + + {% endfor %} +
+
+
+ {% translate "Is this a person or organization?" %} + + +
+
+

{% translate "Name" %}

+
+ + + + +
+
+ +
+

{% translate "Mailing address" %}

+
+ + + + + +
+
+
+

+ {% translate "Contact information" %} {% translate "Optional" %} +

+
+ + +
+
+
+ {% translate "Back to party list" %} + +
+
+
+{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/your_information.html b/efile_app/efile/templates/efile/your_information.html new file mode 100644 index 0000000..2d6990e --- /dev/null +++ b/efile_app/efile/templates/efile/your_information.html @@ -0,0 +1,143 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Your information" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "People" %}
+

{% translate "Your information" %}

+

+ {% translate "Confirm how the court can contact you. We filled in what we could from your eFile account." %} +

+
+ {% csrf_token %} + +
+

{% translate "Name" %}

+
+ + + + +
+
+
+

{% translate "Mailing address" %}

+
+ + + + + +
+
+
+

{% translate "Contact information" %}

+
+ + +
+
+
+ {% if return_to == "review" %} + {% translate "Back to review" %} + + {% else %} + {% translate "Back" %} + + {% endif %} +
+
+
+{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/tests/test_document_prep.py b/efile_app/efile/tests/test_document_prep.py index 75ae4d1..c8009d7 100644 --- a/efile_app/efile/tests/test_document_prep.py +++ b/efile_app/efile/tests/test_document_prep.py @@ -237,15 +237,13 @@ def test_organize_saves_details_and_supporting_order(client, document_draft): assert lead.courtesy_copy_email == "filer@example.com" assert lead.requested_optional_services == ["certified"] assert lead.filing_requires_amount_in_controversy is True + second.refresh_from_db() + assert second.role == FilingDocument.Role.LEAD assert list( document_draft.documents.filter(role=FilingDocument.Role.SUPPORTING) .order_by("sort_order") .values_list("pk", flat=True) ) == [lead.pk, first.pk] - - second.refresh_from_db() - assert second.role == FilingDocument.Role.LEAD - saved = read_upload_data(document_draft) assert saved["lead_filing_component"] == "attachment" assert saved["supporting_documents"][0]["filing_type"] == "petition" diff --git a/efile_app/efile/tests/test_people_flow.py b/efile_app/efile/tests/test_people_flow.py new file mode 100644 index 0000000..28d3396 --- /dev/null +++ b/efile_app/efile/tests/test_people_flow.py @@ -0,0 +1,502 @@ +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 guess_filer_party_type +from efile.workflow import ExistingCase, WorkflowStepKey + +PARTY_TYPES = [ + {"code": "plaintiff", "name": "Plaintiff", "required": True}, + {"code": "defendant", "name": "Defendant", "required": True}, +] + + +def authorize(client, draft): + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session["jurisdiction"] = "illinois" + session.save() + + +@pytest.fixture +def people_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="people-user", 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.YOUR_INFORMATION, + ) + client.force_login(user) + authorize(client, draft) + return draft + + +@pytest.mark.django_db +def test_your_information_persists_filer_contact(client, people_draft): + response = client.post( + reverse("your_information", kwargs={"jurisdiction": "illinois"}), + { + "first_name": "Jamie", + "last_name": "Rivera", + "address_line_1": "100 State Street", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + "email": "jamie@example.com", + "phone": "312-555-0100", + }, + ) + + people_draft.refresh_from_db() + filer = people_draft.parties.get(role="filer") + assert response.status_code == 302 + assert response.url == reverse("parties", kwargs={"jurisdiction": "illinois"}) + assert filer.first_name == "Jamie" + assert filer.address_line_1 == "100 State Street" + assert people_draft.current_step == WorkflowStepKey.PARTIES + + +@pytest.mark.django_db +def test_your_information_suffix_is_a_dropdown_fed_by_the_court(client, people_draft): + response = client.get(reverse("your_information", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + content = response.content.decode() + suffix_select = re.search(r"]*id=\"suffix\"[^>]*>", content) + assert suffix_select is not None + assert 'data-court="cook:cvd1"' in content + + +@pytest.mark.django_db +def test_your_information_returns_to_review_when_edited_from_there(client, people_draft): + response = client.post( + reverse("your_information", kwargs={"jurisdiction": "illinois"}), + { + "first_name": "Jamie", + "last_name": "Rivera", + "address_line_1": "100 State Street", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + "email": "jamie@example.com", + "return_to": "review", + }, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert people_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_guess_filer_party_type_suggests_the_initiator_for_a_new_case(people_draft): + guess = guess_filer_party_type(people_draft, PARTY_TYPES) + + assert guess == {"code": "plaintiff", "name": "Plaintiff", "required": True} + + +@pytest.mark.django_db +def test_guess_filer_party_type_suggests_the_respondent_for_an_answer(people_draft): + people_draft.existing_case = ExistingCase.EXISTING + people_draft.save(update_fields=["existing_case", "updated_at"]) + FilingDocument.objects.create( + draft=people_draft, + role=FilingDocument.Role.LEAD, + name="answer.pdf", + filing_type_name="Answer to Complaint", + ) + + guess = guess_filer_party_type(people_draft, PARTY_TYPES) + + assert guess == {"code": "defendant", "name": "Defendant", "required": True} + + +@pytest.mark.django_db +def test_guess_filer_party_type_is_none_without_a_clear_signal(people_draft): + people_draft.existing_case = ExistingCase.EXISTING + people_draft.save(update_fields=["existing_case", "updated_at"]) + + assert guess_filer_party_type(people_draft, PARTY_TYPES) is None + + +@pytest.mark.django_db +def test_parties_page_offers_the_guess_as_a_button_not_a_prefill(client, people_draft): + FilingParty.objects.create(draft=people_draft, role="filer", sort_order=0) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.get(reverse("parties", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + content = response.content.decode() + assert 'id="apply-party-type-guess"' in content + assert 'data-value="plaintiff"' in content + plaintiff_radio = re.search(r']*name="filer_party_type"[^>]*value="plaintiff"[^>]*>', content) + assert plaintiff_radio is not None + assert "checked" not in plaintiff_radio.group() + + +@pytest.mark.django_db +def test_parties_page_hides_the_guess_once_a_role_is_chosen(client, people_draft): + FilingParty.objects.create(draft=people_draft, role="filer", sort_order=0, party_type="defendant") + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.get(reverse("parties", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + assert b'id="apply-party-type-guess"' not in response.content + + +@pytest.mark.django_db +def test_parties_creates_missing_required_party_and_repeats_details(client, people_draft): + filer = FilingParty.objects.create( + draft=people_draft, + role="filer", + sort_order=0, + first_name="Jamie", + last_name="Rivera", + address_line_1="100 State Street", + city="Chicago", + state="IL", + zip_code="60601", + ) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.post( + reverse("parties", kwargs={"jurisdiction": "illinois"}), + {"filer_party_type": "plaintiff"}, + ) + + filer.refresh_from_db() + other = people_draft.parties.get(role="other") + assert response.status_code == 302 + assert response.url.endswith(f"?party={other.pk}") + assert filer.party_type == "plaintiff" + assert other.party_type == "defendant" + assert other.party_type_name == "Defendant" + + +@pytest.mark.django_db +def test_parties_returns_to_review_when_edited_from_there(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", + ) + 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.parties.get_party_types", return_value=PARTY_TYPES): + response = client.post( + reverse("parties", kwargs={"jurisdiction": "illinois"}), + {"filer_party_type": "plaintiff", "return_to": "review"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert people_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_party_details_suffix_is_a_dropdown_fed_by_the_court(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.get(f"{reverse('party_details', kwargs={'jurisdiction': 'illinois'})}?party={party.pk}") + + assert response.status_code == 200 + content = response.content.decode() + suffix_select = re.search(r"]*id=\"suffix\"[^>]*>", content) + assert suffix_select is not None + assert 'data-court="cook:cvd1"' in content + + +@pytest.mark.django_db +def test_party_details_saves_party_and_advances_to_payment_when_no_questions(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", + "address_line_1": "200 Court Avenue", + "city": "Chicago", + "state": "IL", + "zip_code": "60602", + }, + ) + + people_draft.refresh_from_db() + party.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert party.first_name == "Morgan" + assert people_draft.current_step == WorkflowStepKey.PAYMENT + + +@pytest.mark.django_db +def test_party_details_returns_to_review_when_edited_from_there(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", + "address_line_1": "200 Court Avenue", + "city": "Chicago", + "state": "IL", + "zip_code": "60602", + "return_to": "review", + }, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert people_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_case_questions_are_configured_and_saved(client, people_draft): + people_draft.case_type_name = "Dissolution (with children)" + people_draft.current_step = WorkflowStepKey.CASE_QUESTIONS + people_draft.save(update_fields=["case_type_name", "current_step", "updated_at"]) + + response = client.post( + reverse("case_questions", kwargs={"jurisdiction": "illinois"}), + {"has_children": "true", "child_count": "2"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert people_draft.supplemental_fields["has_children"] is True + assert people_draft.supplemental_fields["child_count"] == 2 + assert people_draft.supplemental_fields["_case_questions_required"] is True + + +@pytest.mark.django_db +def test_case_questions_returns_to_review_when_edited_from_there(client, people_draft): + people_draft.case_type_name = "Dissolution (with children)" + people_draft.current_step = WorkflowStepKey.CASE_QUESTIONS + people_draft.save(update_fields=["case_type_name", "current_step", "updated_at"]) + + response = client.post( + reverse("case_questions", kwargs={"jurisdiction": "illinois"}), + {"has_children": "true", "child_count": "2", "return_to": "review"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert people_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_case_questions_asks_for_amount_in_controversy_with_no_other_questions(client, people_draft): + """The early "nothing to ask, skip ahead" exit used to fire even when a + document's filing type required an amount in controversy, since it only + checked the config-driven questions list.""" + FilingDocument.objects.create( + draft=people_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + filing_requires_amount_in_controversy=True, + ) + people_draft.current_step = WorkflowStepKey.CASE_QUESTIONS + people_draft.save(update_fields=["current_step", "updated_at"]) + + response = client.get(reverse("case_questions", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + assert b"Amount in controversy" in response.content + + +@pytest.mark.django_db +def test_case_questions_saves_a_valid_amount_in_controversy(client, people_draft): + FilingDocument.objects.create( + draft=people_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + filing_requires_amount_in_controversy=True, + ) + people_draft.current_step = WorkflowStepKey.CASE_QUESTIONS + people_draft.save(update_fields=["current_step", "updated_at"]) + + response = client.post( + reverse("case_questions", kwargs={"jurisdiction": "illinois"}), + {"amount_in_controversy": "$12,500.00"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("payment", kwargs={"jurisdiction": "illinois"}) + assert people_draft.amount_in_controversy == "12500.00" + + +@pytest.mark.django_db +def test_case_questions_rejects_a_missing_or_invalid_amount(client, people_draft): + FilingDocument.objects.create( + draft=people_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + filing_requires_amount_in_controversy=True, + ) + people_draft.current_step = WorkflowStepKey.CASE_QUESTIONS + people_draft.save(update_fields=["current_step", "updated_at"]) + + response = client.post( + reverse("case_questions", kwargs={"jurisdiction": "illinois"}), + {"amount_in_controversy": "not a number"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 200 + assert people_draft.amount_in_controversy == "" + assert people_draft.current_step == WorkflowStepKey.CASE_QUESTIONS + + +@pytest.mark.django_db +def test_parties_routes_to_case_questions_when_amount_in_controversy_is_needed(client, people_draft): + """Even with no config-driven case-type questions, a document that needs + an amount in controversy must still route through case_questions instead + of straight to payment.""" + 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", + ) + 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", + ) + FilingDocument.objects.create( + draft=people_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + filing_requires_amount_in_controversy=True, + ) + + with patch("efile.views.parties.get_party_types", return_value=PARTY_TYPES): + response = client.post( + reverse("parties", kwargs={"jurisdiction": "illinois"}), + {"filer_party_type": "plaintiff"}, + ) + + people_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_questions", kwargs={"jurisdiction": "illinois"}) + assert people_draft.current_step == WorkflowStepKey.CASE_QUESTIONS + + +@pytest.mark.django_db +def test_new_parties_are_available_to_legacy_payload_bridge(people_draft): + FilingParty.objects.create( + draft=people_draft, + role="filer", + sort_order=0, + party_type="plaintiff", + party_type_name="Plaintiff", + first_name="Jamie", + last_name="Rivera", + address_line_1="100 State Street", + city="Chicago", + state="IL", + zip_code="60601", + ) + + data = read_case_data(people_draft) + + assert data["party_type"] == "plaintiff" + assert data["petitioner_first_name"] == "Jamie" + assert data["filing_parties"][0]["party_type_name"] == "Plaintiff" diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 76e7dfb..f5ebb12 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -5,6 +5,7 @@ from .views.api_views import get_case_data_api, get_filing_components from .views.case_confirmation import case_confirmation from .views.case_lookup import case_lookup +from .views.case_questions import case_questions from .views.choose_jurisdiction import choose_jurisdiction from .views.confirmation import filing_confirmation from .views.document_checklist import document_checklist @@ -13,10 +14,11 @@ from .views.extraction_review import extraction_review from .views.filing_path import filing_path from .views.filing_statuses import filing_statuses -from .views.legacy_workflow import legacy_workflow_redirect from .views.login import efile_login, efile_logout, efile_password_reset from .views.options import efile_options from .views.organize_documents import organize_documents +from .views.parties import parties +from .views.party_details import party_details from .views.payment import efile_payment from .views.register import efile_register from .views.review import case_review @@ -34,6 +36,7 @@ from .views.upload import efile_upload from .views.upload_documents import upload_documents from .views.upload_first import efile_upload_first +from .views.your_information import your_information def homepage(request): @@ -61,30 +64,10 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//case-confirmation/", case_confirmation, name="case_confirmation"), path("jurisdiction//document-checklist/", document_checklist, name="document_checklist"), path("jurisdiction//organize-documents/", organize_documents, name="organize_documents"), - path( - "jurisdiction//your-information/", - legacy_workflow_redirect, - {"destination": "your_information"}, - name="your_information", - ), - path( - "jurisdiction//parties/", - legacy_workflow_redirect, - {"destination": "parties"}, - name="parties", - ), - path( - "jurisdiction//party-details/", - legacy_workflow_redirect, - {"destination": "party_details"}, - name="party_details", - ), - path( - "jurisdiction//case-questions/", - legacy_workflow_redirect, - {"destination": "case_questions"}, - name="case_questions", - ), + path("jurisdiction//your-information/", your_information, name="your_information"), + path("jurisdiction//parties/", parties, name="parties"), + path("jurisdiction//party-details/", party_details, name="party_details"), + path("jurisdiction//case-questions/", case_questions, name="case_questions"), path("jurisdiction//drafts/", create_draft_view, name="create_draft"), path("jurisdiction//filing_statuses/", filing_statuses, name="filing_statuses"), path("jurisdiction//expert_form/", efile_expert_form, name="expert_form"), diff --git a/efile_app/efile/views/case_questions.py b/efile_app/efile/views/case_questions.py new file mode 100644 index 0000000..583483b --- /dev/null +++ b/efile_app/efile/views/case_questions.py @@ -0,0 +1,103 @@ +from decimal import Decimal, InvalidOperation + +from django.contrib import messages +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot +from efile.services.people import get_case_questions, needs_amount_in_controversy, parse_question_answer +from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context + + +def _parse_amount(raw: str) -> Decimal | None: + cleaned = raw.replace(",", "").replace("$", "").strip() + try: + amount = Decimal(cleaned) + except InvalidOperation: + return None + return amount if amount > 0 else None + + +@require_http_methods(["GET", "POST"]) +def case_questions(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.CASE_QUESTIONS, + workflow_version=2, + ) + questions = get_case_questions(draft) + show_amount_field = needs_amount_in_controversy(draft) + if not questions and not show_amount_field: + return_to = request.POST.get("return_to") or request.GET.get("return_to") + next_step = WorkflowStepKey.REVIEW if return_to == RETURN_TO_REVIEW else WorkflowStepKey.PAYMENT + draft.current_step = next_step + draft.save(update_fields=["current_step", "updated_at"]) + return redirect(get_step_url(next_step, jurisdiction)) + + for question in questions: + raw_value = (draft.supplemental_fields or {}).get(question["name"], "") + if question["type"] == "radio": + raw_value = "" if raw_value in (None, "") else str(raw_value).lower() + elif raw_value is None: + raw_value = "" + question["value"] = raw_value + + if request.method == "POST": + answers = {} + errors = [] + for question in questions: + value = request.POST.get(question["name"], "") + if question["name"] == "child_count" and request.POST.get("has_children") == "false": + value = "" + if question["required"] and value == "" and question["name"] != "child_count": + errors.append(question["label"]) + continue + try: + answers[question["name"]] = parse_question_answer(question, value) + except ValueError: + errors.append(question["label"]) + if request.POST.get("has_children") == "true" and not request.POST.get("child_count"): + errors.append("Number of minor children") + + amount_in_controversy = None + if show_amount_field: + amount_in_controversy = _parse_amount(request.POST.get("amount_in_controversy", "")) + if amount_in_controversy is None: + errors.append("Amount in controversy") + + if errors: + messages.error(request, f"Answer these questions: {', '.join(dict.fromkeys(errors))}.") + else: + draft.supplemental_fields = { + **(draft.supplemental_fields or {}), + **answers, + "_case_questions_required": True, + } + update_fields = ["supplemental_fields", "current_step", "updated_at"] + if show_amount_field: + draft.amount_in_controversy = str(amount_in_controversy) + update_fields.append("amount_in_controversy") + next_step = ( + WorkflowStepKey.REVIEW if request.POST.get("return_to") == RETURN_TO_REVIEW else WorkflowStepKey.PAYMENT + ) + draft.current_step = next_step + draft.save(update_fields=update_fields) + return redirect(get_step_url(next_step, jurisdiction)) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "questions": questions, + "answers": draft.supplemental_fields or {}, + "return_to": request.GET.get("return_to", ""), + "show_amount_field": show_amount_field, + "amount_in_controversy": draft.amount_in_controversy, + } + context.update(get_workflow_context(WorkflowStepKey.CASE_QUESTIONS, jurisdiction, draft)) + return render(request, "efile/case_questions.html", context) diff --git a/efile_app/efile/views/parties.py b/efile_app/efile/views/parties.py new file mode 100644 index 0000000..0f51ba6 --- /dev/null +++ b/efile_app/efile/views/parties.py @@ -0,0 +1,112 @@ +from django.contrib import messages +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.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot +from efile.services.people import ( + ensure_required_parties, + get_case_questions, + get_party_types, + guess_filer_party_type, + incomplete_parties, + needs_amount_in_controversy, + party_is_complete, +) +from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to + + +def _party_details_url(jurisdiction, party, return_to=None): + url = f"{reverse('party_details', kwargs={'jurisdiction': jurisdiction})}?party={party.pk}" + return with_return_to(url, return_to) + + +def _parties_url(jurisdiction, return_to=None): + return with_return_to(reverse("parties", kwargs={"jurisdiction": jurisdiction}), return_to) + + +@require_http_methods(["GET", "POST"]) +def parties(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.PARTIES, + workflow_version=2, + ) + filer = FilingParty.objects.filter(draft=draft, role="filer").first() + if filer is None: + return redirect("your_information", jurisdiction=jurisdiction) + party_types = get_party_types(draft) + party_type_names = {item["code"]: item["name"] for item in party_types} + + if request.method == "POST": + action = request.POST.get("action", "continue") + return_to = request.POST.get("return_to") + if action == "add": + last_order = ( + FilingParty.objects.filter(draft=draft, role="other") + .order_by("-sort_order") + .values_list("sort_order", flat=True) + .first() + ) + party = FilingParty.objects.create( + draft=draft, + role="other", + sort_order=0 if last_order is None else last_order + 1, + ) + 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 == "remove": + party = get_object_or_404(FilingParty, pk=request.POST.get("party_id"), draft=draft, role="other") + party.delete() + messages.success(request, "Party removed.") + return redirect(_parties_url(jurisdiction, return_to)) + + filer_type = request.POST.get("filer_party_type", "").strip() + if not filer_type or filer_type not in party_type_names: + messages.error(request, "Choose your role in this case.") + else: + 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) + 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)) + + roster = [ + {"party": party, "complete": party_is_complete(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) + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "filer": filer, + "return_to": request.GET.get("return_to", ""), + "party_types": party_types, + "roster": roster, + "guessed_party_type": guessed_party_type, + } + 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 new file mode 100644 index 0000000..5ab3713 --- /dev/null +++ b/efile_app/efile/views/party_details.py @@ -0,0 +1,89 @@ +from django.contrib import messages +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.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.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_step_url, get_workflow_context, with_return_to + + +@require_http_methods(["GET", "POST"]) +def party_details(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.PARTY_DETAILS, + workflow_version=2, + ) + 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} + + if request.method == "POST": + party_kind = request.POST.get("party_kind", "person") + party_type = request.POST.get("party_type", "").strip() + if party_type and party_type not in party_type_names: + party_type = "" + 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_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(), + } + 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.") + else: + party.party_type = party_type + party.party_type_name = party_type_names.get(party_type, party.party_type_name) + party.organization_name = organization_name if party_kind == "organization" else "" + party.first_name = first_name if party_kind == "person" else "" + 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(): + setattr(party, field, value) + party.address_line_2 = request.POST.get("address_line_2", "").strip() + 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] + if remaining: + url = reverse("party_details", kwargs={"jurisdiction": jurisdiction}) + return redirect(with_return_to(f"{url}?party={remaining[0].pk}", 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)) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "party": party, + "party_types": party_types, + "party_kind": "organization" if party.organization_name else "person", + "return_to": request.GET.get("return_to", ""), + "court_code": draft.court_code, + } + context.update(get_workflow_context(WorkflowStepKey.PARTY_DETAILS, jurisdiction, draft)) + return render(request, "efile/party_details.html", context) diff --git a/efile_app/efile/views/your_information.py b/efile_app/efile/views/your_information.py new file mode 100644 index 0000000..f80a56e --- /dev/null +++ b/efile_app/efile/views/your_information.py @@ -0,0 +1,62 @@ +from django.contrib import messages +from django.shortcuts import redirect, render +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.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot +from efile.workflow import RETURN_TO_REVIEW, WorkflowStepKey, get_return_url, get_workflow_context + + +@require_http_methods(["GET", "POST"]) +def your_information(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.YOUR_INFORMATION, + workflow_version=2, + ) + filer, _created = FilingParty.objects.get_or_create(draft=draft, role="filer", sort_order=0) + + if request.method == "POST": + required = { + "first_name": "First name", + "last_name": "Last name", + "address_line_1": "Street address", + "city": "City", + "state": "State", + "zip_code": "ZIP code", + "email": "Email", + } + values = {field: request.POST.get(field, "").strip() for field in required} + missing = [label for field, label in required.items() if not values[field]] + if missing: + messages.error(request, f"Complete these fields: {', '.join(missing)}.") + else: + for field, value in values.items(): + setattr(filer, field, value) + filer.middle_name = request.POST.get("middle_name", "").strip() + filer.suffix = request.POST.get("suffix", "").strip() + filer.address_line_2 = request.POST.get("address_line_2", "").strip() + filer.phone = request.POST.get("phone", "").strip() + filer.save() + next_step = ( + WorkflowStepKey.REVIEW if request.POST.get("return_to") == RETURN_TO_REVIEW else WorkflowStepKey.PARTIES + ) + draft.current_step = next_step + draft.save(update_fields=["current_step", "updated_at"]) + return redirect(get_return_url(request, jurisdiction, WorkflowStepKey.PARTIES)) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "filer": filer, + "return_to": request.GET.get("return_to", ""), + "court_code": draft.court_code, + } + context.update(get_workflow_context(WorkflowStepKey.YOUR_INFORMATION, jurisdiction, draft)) + return render(request, "efile/your_information.html", context) diff --git a/efile_app/efile/workflow.py b/efile_app/efile/workflow.py index a193417..5b89eed 100644 --- a/efile_app/efile/workflow.py +++ b/efile_app/efile/workflow.py @@ -334,6 +334,16 @@ def get_return_url(request: Any, jurisdiction: str, default_step: WorkflowStepKe return get_step_url(default_step, jurisdiction) +def with_return_to(url: str, return_to: str | None) -> str: + """Carry the return_to marker across an intermediate redirect (e.g. to fill + in one more required party) so it survives to reach the step it names.""" + + if not return_to: + return url + separator = "&" if "?" in url else "?" + return f"{url}{separator}return_to={return_to}" + + def get_resume_step_url(current_step: WorkflowStepKey | str | None, jurisdiction: str) -> str | None: if current_step is None: return None