From 401a9543cca18ec480929453587d291e6bb67f7c Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Thu, 22 Oct 2026 11:58:36 -0400 Subject: [PATCH 1/6] Streamline interview handoff review and continuation --- docs/developer-notes/issue-166-handoff-ux.md | 35 +++++++ efile_app/efile/services/handoff.py | 16 +++- .../efile/templates/efile/handoff_claim.html | 2 +- .../efile/templates/efile/handoff_review.html | 69 ++++++++++---- efile_app/efile/templates/efile/review.html | 5 +- efile_app/efile/tests/test_handoff.py | 95 +++++++++++++++++++ efile_app/efile/views/handoff.py | 53 ++++++++++- 7 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 docs/developer-notes/issue-166-handoff-ux.md diff --git a/docs/developer-notes/issue-166-handoff-ux.md b/docs/developer-notes/issue-166-handoff-ux.md new file mode 100644 index 00000000..0b840c74 --- /dev/null +++ b/docs/developer-notes/issue-166-handoff-ux.md @@ -0,0 +1,35 @@ +# Streamlining the RFA interview-to-filing flow + +Reviewed on October 22, 2026 for [issue 166](https://github.com/SuffolkLITLab/LITEfile/issues/166). The RFA changes build on the still-open [sender PR 426](https://github.com/LSVermont/docassemble-RFApackage/pull/426), branch `feature/litefile-handoff`. The shared sender now lives in `SuffolkLITLab/docassemble-LITEfile`, pinned by RFA to v0.1.0. + +## Changes in this pass + +- Put the next action above the imported information. Group missing fields into one task per destination screen. +- Keep coordinated case and filing choices editable in an optional details section; reserve correction confirmation for actual correction requirements. +- Approve the transferred PDF copies on the handoff page using the existing document fingerprint check. Previously final review redirected the user back through the case-information form solely because those PDFs were not marked reviewed. +- Match ordinary document readiness: honor an explicitly confirmed empty confidentiality choice, and leave filing-component validation to the existing final payload checks. Do not supply new confidentiality defaults. Clerk-requested corrections still block continuation. +- Skip repeated account claiming for an already-owned draft. Use “Your interview” instead of an internal source identifier. +- Add a direct link to the submission section while keeping court terms, the attestation, and the submit action together. +- In RFA, put electronic filing first when enabled; replace download-only introductory and completion text only in that mode. Keep the manual filing instructions when disabled. Prefill service-question city and state from the previously supplied defendant address; leave both editable. + +## Independent reviews + +Requested read-only reviews from `agy --model gemini-4-argon-high --mode plan` and Claude CLI in plan mode. Both completed. Argon traced the original flow; Claude also inspected the in-progress edits. These were source reviews, not independent live staging sessions. + +Both identified the duplicate case-confirmation loop, buried actions, outdated shared Vermont text, and optional document-setting inconsistencies. Claude also caught that returning to the interview updates PDFs without synchronizing the saved filing metadata. The action now says “Fix a PDF in my interview” and explains that changed names, addresses, or court details must also be edited in LITEfile. Model findings were checked against the implementation; proposed blanket PDF approval and unverified confidentiality defaults were not adopted. + +## Proposed next improvements + +1. **Synchronize interview corrections:** design a revision protocol for changed names, addresses, county, and added/removed forms. The current replacement endpoint deliberately updates only known PDFs. Do this before promising an unrestricted “Edit answers” round trip. +2. **Verify confidential contact handling:** RFA asks `share_contact`, while its sender exports contact fields to filer/party records. Check which fields the target EFM exposes and represent the interview's privacy choice explicitly. Do not treat PDF confidentiality as proof that party metadata is confidential. +3. **Shorten the shared sender transition:** consider continuing directly after a successful transfer, while retaining the retry/recovery screen on failure. This belongs in `docassemble-LITEfile`, with a package release and RFA dependency update. +4. **Reuse account email carefully:** a missing interview email currently opens a form that defaults to the account email. Consider using that known value earlier while preserving an obvious way to choose a safe contact address. +5. **Unify filing instructions:** review the RFA NEXT STEPS document alongside the new online path, so users understand when they have already submitted and what remains to do. Do not change legal or emergency-filing instructions without partner review. +6. **Resolve only verified choices:** add maintained semantic aliases or single required-component handling where live metadata supports them. Leave ambiguous matches and confidentiality decisions visible; do not guess that ordinary RFA forms should be public. + +## Validation and limits + +- Handoff, final-review, and PDF-preview tests: 89 passed before the optional-settings refinement; final handoff suite: 43 passed. Ruff and Ty pass. +- Parsed the RFA YAML and rendered its introduction/download Mako templates with synthetic helpers in both enabled and disabled modes. Confirmed the enabled mode omits the contradictory manual-only instructions and puts the filing action before downloads. +- Chromium at 390 × 844: ready/missing handoff primary actions are above the fold; the saved-details disclosure opens and closes with Enter. [Synthetic screenshots](https://gist.github.com/nonprofittechy/841e3dd9efb519a3cc91e481aa1f1a40). +- These checks do not establish a successful Vermont test filing. Full Docassemble execution, test-EFM submission, court receipt/status, and Vermont partner acceptance remain outstanding under issue 166. diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index 85c8a90c..095eac31 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -507,8 +507,8 @@ def need(path, value, message, view): for doc in draft.documents.all(): for field, label in ( ("filing_type_code", "filing type"), - ("document_type_code", "document type"), - ("filing_component_code", "filing component"), + ("document_type_code", "confidentiality setting"), + ("filing_component_code", "document settings"), ): if ( current_metadata @@ -516,8 +516,18 @@ def need(path, value, message, view): and f"documents.{doc.pk}.{field}" not in draft.correction_fields ): continue + path = f"documents.{doc.pk}.{field}" + if path not in draft.correction_fields: + # Match the ordinary workflow's document readiness rules. The + # document editor confirms an empty confidentiality choice when + # the court offers none; components are validated by the final + # payload checks, not a mandatory question for every filer. + if field == "filing_component_code": + continue + if field == "document_type_code" and doc.document_type_confirmed: + continue need( - f"documents.{doc.pk}.{field}", + path, getattr(doc, field), f"Choose the {label} for {doc.name}.", "organize_documents", diff --git a/efile_app/efile/templates/efile/handoff_claim.html b/efile_app/efile/templates/efile/handoff_claim.html index 764fd74b..6e529b70 100644 --- a/efile_app/efile/templates/efile/handoff_claim.html +++ b/efile_app/efile/templates/efile/handoff_claim.html @@ -9,7 +9,7 @@

This draft belongs to another account

Sign in with the account you used to claim the draft.

{% else %}

Continue your filing

-

{{ source }} sent your answers and documents. Add them to your account to continue filing.

+

Your interview sent your answers and documents. Add them to your account to continue filing.

{% csrf_token %}
{% endif %} {% endblock public_content %} diff --git a/efile_app/efile/templates/efile/handoff_review.html b/efile_app/efile/templates/efile/handoff_review.html index cfb9c76d..9e806c14 100644 --- a/efile_app/efile/templates/efile/handoff_review.html +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -16,28 +16,59 @@

{% else %} {% if draft.correction_of_id %}

Your previous filing and documents stay saved.

{% endif %} {% for comment in draft.clerk_return.comments %}

{{ comment.text }}

{% endfor %} -

Complete the missing details and fix any mistakes.

-
{% csrf_token %}
-

Details to complete

- -
{% csrf_token %}
-

Saved case information

-

- {{ draft.court_name }} - {% if draft.is_filing_into_found_case %}{{ draft.docket_number }}{% endif %} - {{ draft.case_type_name }} -

- Edit case details -

People

- - Edit people and party types +

Your answers and forms are saved. Nothing has been sent to the court yet.

+ {% if review_error %}{% endif %} + {% if issues %} +

Before you continue

+

We only need the details listed here. Your other answers are already saved.

+

Complete missing details

+ + {% else %} + {% if needs_document_review %} +

Open your PDFs below and check the copies the court will receive. You can edit your answers if anything needs to change.

+ {% else %} +

Your draft is ready for the final filing checks.

+ {% endif %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% if draft.correction_fields %} +
{% csrf_token %}
+ {% endif %}

Documents

- - Edit documents and filing types + {% for document in documents %} +
+ {% include "efile/components/document_preview.html" with jurisdiction=draft.jurisdiction %} + {% if document.document_type_name %}

Confidentiality: {{ document.document_type_name }}

{% endif %} +
+ {% endfor %} {% if can_return %} -
{% csrf_token %}
+
{% csrf_token %}
+

If you change a name, address, or court in the interview, update the saved filing details here too.

{% endif %} -

Review and submit

+
+ View or change saved filing details +

Your interview supplied these details. LITEfile matched the court choices where possible.

+

Case information

+

+ {{ draft.court_name }} + {% if draft.is_filing_into_found_case %}{{ draft.docket_number }}{% endif %} + {{ draft.case_type_name }} +

+ Edit case details +

People

+ + Edit people +

Filing types

+ + Edit documents and filing types +
{% csrf_token %}
+
{% endif %} {% endblock public_content %} {% block extra_css %} diff --git a/efile_app/efile/templates/efile/review.html b/efile_app/efile/templates/efile/review.html index 6a9e616f..eb0c7608 100644 --- a/efile_app/efile/templates/efile/review.html +++ b/efile_app/efile/templates/efile/review.html @@ -23,7 +23,8 @@

Before you can submit

{% endif %} {% endif %} -

{% translate "Check each section carefully. If you need to make changes, use Edit to go back to the screen where you entered it." %}

+

{% translate "Review your documents and filing summary. Use Edit if anything needs to change." %}

+

{% translate "Go to submission" %}

{% include "efile/partials/filing_guidance.html" %} {% if extracted_markers.has_any %}
@@ -342,7 +343,7 @@

{% translate "Payment" %}

-

{% translate "Before you submit your filing" %}

+

{% translate "Before you submit your filing" %}

{% if disclaimer_error %} {% else %} diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index 6e32a72e..6f0b9579 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -766,3 +766,98 @@ def test_handoff_distinguishes_conversion_rejection_from_service_outage( assert response.status_code == expected assert not FilingDraft.objects.exists() storage.upload_file.assert_not_called() + + +def test_claimed_handoff_resumes_without_claiming_again(client, source, payload, storage, django_user_model): + response = send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + + response = client.get(response.json()["continue_url"]) + + assert response.status_code == 302 + assert response.url == reverse("handoff_review", args=[draft.pk]) + + +@pytest.mark.parametrize("fingerprint_state", ["current", "stale", "missing"]) +def test_handoff_reviews_current_pdfs_without_reasking_case_details( + client, source, payload, storage, django_user_model, fingerprint_state +): + from efile.services.document_previews import preview_fingerprint + + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + document = draft.documents.get() + fingerprint = preview_fingerprint([document]) + if fingerprint_state == "stale": + document.s3_key = "replacement.pdf" + document.save() + elif fingerprint_state == "missing": + fingerprint = "" + + # Isolate PDF acknowledgement from the separate live taxonomy/readiness checks. + with patch("efile.views.handoff.resolve_metadata"), patch("efile.views.handoff.issues_for", return_value=[]): + page = client.get(reverse("handoff_review", args=[draft.pk])) + assert b"Confirm my corrections" not in page.content + assert page.content.index(b"Continue to final review") < page.content.index(b"

Documents

") + response = client.post( + reverse("handoff_review", args=[draft.pk]), + {"action": "continue", "preview_fingerprint": fingerprint}, + ) + + document.refresh_from_db() + if fingerprint_state == "current": + assert response.status_code == 302 + assert reverse("case_review", kwargs={"jurisdiction": "vermont"}) in response.url + assert document.preparation_reviewed_at is not None + else: + assert response.status_code == 422 + assert document.preparation_reviewed_at is None + assert b"Your files changed" in response.content + draft.refresh_from_db() + assert draft.status == FilingDraft.Status.DRAFT + + +def test_handoff_continue_keeps_missing_details_blocking(client, source, payload, storage, django_user_model): + from efile.services.document_previews import preview_fingerprint + + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + with patch("efile.views.handoff.resolve_metadata"): + page = client.get(reverse("handoff_review", args=[draft.pk])) + assert b"Complete missing details" in page.content + assert b"Continue to final review" not in page.content + response = client.post( + reverse("handoff_review", args=[draft.pk]), + {"action": "continue", "preview_fingerprint": preview_fingerprint(draft.documents.all())}, + ) + assert response.url == reverse("handoff_review", args=[draft.pk]) + assert draft.documents.get().preparation_reviewed_at is None + + +def test_handoff_does_not_reask_confirmed_optional_document_settings(client, source, payload, storage): + from efile.services.handoff import issues_for + + send(client, source, payload) + draft = FilingDraft.objects.get() + document = draft.documents.get() + document.filing_type_code = "complaint" + document.document_type_confirmed = True + document.save() + paths = {issue["path"] for issue in issues_for(draft)} + assert f"documents.{document.pk}.document_type_code" not in paths + assert f"documents.{document.pk}.filing_component_code" not in paths + + # An unresolved confidentiality choice and clerk-requested corrections must + # still be completed, even when the normal document editor was visited. + document.document_type_confirmed = False + document.save() + assert f"documents.{document.pk}.document_type_code" in {issue["path"] for issue in issues_for(draft)} + draft.correction_fields = [f"documents.{document.pk}.filing_component_code"] + draft.save() + assert draft.correction_fields[0] in {issue["path"] for issue in issues_for(draft)} diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 4d41c33b..3a589f8f 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -24,6 +24,12 @@ cleanup_unreferenced_uploads, store_prepared_document, ) +from efile.services.document_previews import ( + DocumentReviewError, + approve_document_review, + preview_fingerprint, + unreviewed_documents, +) from efile.services.draft_urls import draft_url from efile.services.fee_quotes import invalidate_fee_quote from efile.services.filings import fetch_filing_detail @@ -180,6 +186,8 @@ def handoff_claim(request, token): return _private(redirect("efile_login", jurisdiction=draft.jurisdiction)) if draft.user_id and draft.user_id != request.user.pk: return _private(render(request, "efile/handoff_claim.html", {"unavailable": True}, status=403)) + if request.method == "GET" and draft.user_id == request.user.pk: + return _private(redirect("handoff_review", draft_id=draft.pk)) if request.method == "POST": with transaction.atomic(): draft = FilingDraft.objects.select_for_update().get(pk=draft.pk) @@ -219,6 +227,8 @@ def handoff_review(request, draft_id): if draft.status != FilingDraft.Status.DRAFT: return _private(render(request, "efile/handoff_review.html", {"draft": draft, "closed": True})) attach_current_draft(request, draft) + review_error = "" + status = 200 if request.method == "POST": resolve_metadata(draft) if request.POST.get("action") == "confirm": @@ -239,20 +249,54 @@ def handoff_review(request, draft_id): draft.correction_fields = remaining draft.save(update_fields=["correction_fields", "updated_at"]) record(draft, "review", "user_confirmation", {"user_id": request.user.pk}) - return redirect("handoff_review", draft_id=draft.pk) + if request.POST.get("action") == "continue": + if issues_for(draft): + return redirect("handoff_review", draft_id=draft.pk) + try: + # Review the transferred PDFs here, without asking the filer to + # re-enter the interview's resolved court and case information. + if unreviewed_documents(draft).exists() or request.POST.get("preview_fingerprint"): + approve_document_review(draft, request.POST.get("preview_fingerprint")) + except DocumentReviewError as exc: + review_error, status = str(exc), exc.status + else: + return redirect( + draft_url(reverse("case_review", kwargs={"jurisdiction": draft.jurisdiction}), draft.pk) + ) + else: + return redirect("handoff_review", draft_id=draft.pk) receipt = receipt_for(draft) + documents = list(draft.documents.all()) + issues = _issue_links(draft) + labels = { + "extraction_review": "Complete case details", + "case_lookup": "Find your court case", + "case_confirmation": "Confirm the court case", + "upload_documents": "Add or replace documents", + "organize_documents": "Complete document settings", + "your_information": "Complete your contact information", + "parties": "Complete information about the people in this case", + "case_questions": "Answer the remaining court questions", + "payment": "Check fees and choose a payment method or fee waiver", + } + steps = {} + for issue in issues: + steps.setdefault(issue["url"], {"url": issue["url"], "label": labels.get(issue["view"], issue["message"])}) return _private( render( request, "efile/handoff_review.html", { "draft": draft, - "issues": _issue_links(draft), + "issues": issues, + "steps": list(steps.values()), "receipt": receipt, - "documents": draft.documents.all(), + "documents": documents, + "preview_fingerprint": preview_fingerprint(documents), + "needs_document_review": any(doc.preparation_reviewed_at is None for doc in documents), + "review_error": review_error, "parties": draft.parties.all(), "can_return": bool(receipt and receipt.payload.get("return_url")), - "review_url": draft_url(reverse("case_review", kwargs={"jurisdiction": draft.jurisdiction}), draft.pk), "case_url": draft_url( with_return_to( reverse("extraction_review", kwargs={"jurisdiction": draft.jurisdiction}), RETURN_TO_HANDOFF @@ -270,6 +314,7 @@ def handoff_review(request, draft_id): draft.pk, ), }, + status=status, ) ) From 3b353b64ae1bab91a18264bd74d18c00bde3a72e Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 23 Oct 2026 10:13:07 -0400 Subject: [PATCH 2/6] Expose imported answers and unmatched court hints on handoff --- .../efile/templates/efile/handoff_review.html | 74 ++++++++++++++----- efile_app/efile/tests/test_handoff.py | 30 ++++++++ efile_app/efile/views/handoff.py | 52 +++++++++---- 3 files changed, 122 insertions(+), 34 deletions(-) diff --git a/efile_app/efile/templates/efile/handoff_review.html b/efile_app/efile/templates/efile/handoff_review.html index 9e806c14..a0409cfa 100644 --- a/efile_app/efile/templates/efile/handoff_review.html +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -22,7 +22,7 @@

Before you continue

We only need the details listed here. Your other answers are already saved.

Complete missing details

- +
{% else %} {% if needs_document_review %}

Open your PDFs below and check the copies the court will receive. You can edit your answers if anything needs to change.

@@ -40,6 +40,60 @@

Before you continue

{% if draft.correction_fields %}
{% csrf_token %}
{% endif %} +
+ Answers carried over from your interview +

Your interview supplied answers and suggested court choices. Suggestions must match the court’s available choices, which can change. You can edit the saved details below.

+

Case information

+
+ {% for choice in imported_choices %} +
{{ choice.label }}
+
+ {% if choice.hint %}From your interview: {{ choice.hint }}
{% endif %} + {% if choice.value %}Saved court choice: {{ choice.value }}{% else %}Needs a court choice{% endif %} +
+ {% endfor %} + {% if draft.is_filing_into_found_case %}
Case number
{{ draft.docket_number }}
{% endif %} +
+ Edit case details +

People

+
    + {% for party in parties %} +
  • + {{ party }} + {% if party.party_role_hint %}
    Role from your interview: {{ party.party_role_hint }}{% endif %} + {% if party.party_type or party.party_role_hint %} +
    + Saved court role: {{ party.party_type_name|default:"Needs a court choice" }} + {% endif %} + {% if party.address_line_1 %} +
    + {{ party.address_line_1 }} + {% if party.address_line_2 %}, {{ party.address_line_2 }}{% endif %} +
    + {{ party.city }}, {{ party.state }} {{ party.zip_code }} + {% endif %} + {% if party.email %}
    {{ party.email }}{% endif %} +
  • + {% endfor %} +
+ Edit people +

Filing types

+
    + {% for document in documents %} +
  • + {{ document.name }} + {% if document.interview_filing_hint %}
    From your interview: {{ document.interview_filing_hint }}{% endif %} +
    + Saved court choice: {{ document.filing_type_name|default:"Needs a court choice" }} +
  • + {% endfor %} +
+ Edit documents and filing types +
+ {% csrf_token %} + +
+

Documents

{% for document in documents %}
@@ -51,24 +105,6 @@

Documents

{% csrf_token %}

If you change a name, address, or court in the interview, update the saved filing details here too.

{% endif %} -
- View or change saved filing details -

Your interview supplied these details. LITEfile matched the court choices where possible.

-

Case information

-

- {{ draft.court_name }} - {% if draft.is_filing_into_found_case %}{{ draft.docket_number }}{% endif %} - {{ draft.case_type_name }} -

- Edit case details -

People

-
    {% for party in parties %}
  • {{ party }} — {{ party.party_type_name|default:party.party_role_hint }}
  • {% endfor %}
- Edit people -

Filing types

-
    {% for document in documents %}
  • {{ document.name }} — {{ document.filing_type_name }}
  • {% endfor %}
- Edit documents and filing types -
{% csrf_token %}
-
{% endif %} {% endblock public_content %} {% block extra_css %} diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index 6f0b9579..d280202e 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -861,3 +861,33 @@ def test_handoff_does_not_reask_confirmed_optional_document_settings(client, sou draft.correction_fields = [f"documents.{document.pk}.filing_component_code"] draft.save() assert draft.correction_fields[0] in {issue["path"] for issue in issues_for(draft)} + + +def test_imported_hints_and_unmatched_warning_outside_accordion(client, source, payload, storage, django_user_model): + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + with ( + patch("efile.services.people.get_case_questions", return_value=[]), + patch("efile.services.people.needs_amount_in_controversy", return_value=False), + ): + response = client.get(reverse("handoff_review", args=[draft.pk])) + content = response.content.decode() + before, inside = content.split('
', 1) + assert "the interview suggested “Relief from abuse”" in before + assert "but no court choice is matched" in before + assert "From your interview: Relief from abuse" in inside + assert "Needs a court choice" in inside + draft.case_type_code = "current-code" + draft.case_type_name = "Relief from abuse (current court label)" + draft.save() + with ( + patch("efile.services.people.get_case_questions", return_value=[]), + patch("efile.services.people.needs_amount_in_controversy", return_value=False), + ): + response = client.get(reverse("handoff_review", args=[draft.pk])) + before, inside = response.content.decode().split('
', 1) + assert "the interview suggested “Relief from abuse”" not in before + assert "Saved court choice: Relief from abuse (current court label)" in inside + assert "From your interview: Relief from abuse" in inside diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 3a589f8f..b88560c6 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -37,6 +37,7 @@ HandoffError, create_correction, documents_replaced, + effective_hints, fingerprint, issues_for, populate, @@ -267,21 +268,42 @@ def handoff_review(request, draft_id): return redirect("handoff_review", draft_id=draft.pk) receipt = receipt_for(draft) documents = list(draft.documents.all()) + payload = receipt.payload if receipt else {} + hints = effective_hints(payload, court_name=draft.court_name) + imported_choices = [ + { + "label": "Court", + "hint": payload.get("case", {}).get("court_name", ""), + "value": draft.court_name if draft.court_code else "", + }, + { + "label": "Case category", + "hint": ", ".join(hints.get("case_category_name_hints", [])), + "value": draft.case_category_name if draft.case_category_code else "", + }, + { + "label": "Case type", + "hint": ", ".join(hints.get("case_type_name_hints", [])), + "value": draft.case_type_name if draft.case_type_code else "", + }, + ] + for document in documents: + suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() + source_document = suggestion.value if suggestion else {} + document_hints = effective_hints(payload, court_name=draft.court_name, document=source_document) + document.interview_filing_hint = ", ".join( + document_hints.get("filing_type_name_hints", []) + or (hints.get("filing_type_name_hints", []) if document.role == "lead" else []) + ) issues = _issue_links(draft) - labels = { - "extraction_review": "Complete case details", - "case_lookup": "Find your court case", - "case_confirmation": "Confirm the court case", - "upload_documents": "Add or replace documents", - "organize_documents": "Complete document settings", - "your_information": "Complete your contact information", - "parties": "Complete information about the people in this case", - "case_questions": "Answer the remaining court questions", - "payment": "Check fees and choose a payment method or fee waiver", - } - steps = {} - for issue in issues: - steps.setdefault(issue["url"], {"url": issue["url"], "label": labels.get(issue["view"], issue["message"])}) + for choice, field in zip(imported_choices, ("court_code", "case_category_code", "case_type_code"), strict=True): + if choice["hint"] and not choice["value"] and field not in draft.correction_fields: + for issue in issues: + if issue["path"] == field: + issue["message"] = ( + f"{choice['label']}: the interview suggested “{choice['hint']}”, " + "but no court choice is matched. Choose an available option." + ) return _private( render( request, @@ -289,7 +311,7 @@ def handoff_review(request, draft_id): { "draft": draft, "issues": issues, - "steps": list(steps.values()), + "imported_choices": imported_choices, "receipt": receipt, "documents": documents, "preview_fingerprint": preview_fingerprint(documents), From 8bf76e010869bc93b36492852c612cc12cb7fef7 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 23 Oct 2026 10:27:32 -0400 Subject: [PATCH 3/6] Align imported answers in consistently spaced tables --- efile_app/efile/static/css/handoff-review.css | 33 +++++++ .../efile/templates/efile/handoff_review.html | 85 ++++++++++--------- 2 files changed, 80 insertions(+), 38 deletions(-) create mode 100644 efile_app/efile/static/css/handoff-review.css diff --git a/efile_app/efile/static/css/handoff-review.css b/efile_app/efile/static/css/handoff-review.css new file mode 100644 index 00000000..c59b17ce --- /dev/null +++ b/efile_app/efile/static/css/handoff-review.css @@ -0,0 +1,33 @@ +.handoff-details { + width: 100%; + table-layout: fixed; + margin-bottom: 1.5rem; +} + +.handoff-details th, +.handoff-details td { + padding: 0.75rem; + vertical-align: top; + overflow-wrap: anywhere; +} + +.handoff-details th { + width: 34%; + padding-left: 0; +} + +.handoff-details td { + padding-right: 0; +} + +.handoff-details p { + margin: 0; +} + +.handoff-details p+p { + margin-top: 0.5rem; +} + +.handoff-details .handoff-hint { + color: #545454; +} \ No newline at end of file diff --git a/efile_app/efile/templates/efile/handoff_review.html b/efile_app/efile/templates/efile/handoff_review.html index a0409cfa..078a9aae 100644 --- a/efile_app/efile/templates/efile/handoff_review.html +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -44,50 +44,58 @@

Before you continue

Answers carried over from your interview

Your interview supplied answers and suggested court choices. Suggestions must match the court’s available choices, which can change. You can edit the saved details below.

Case information

-
- {% for choice in imported_choices %} -
{{ choice.label }}
-
- {% if choice.hint %}From your interview: {{ choice.hint }}
{% endif %} - {% if choice.value %}Saved court choice: {{ choice.value }}{% else %}Needs a court choice{% endif %} -
- {% endfor %} - {% if draft.is_filing_into_found_case %}
Case number
{{ draft.docket_number }}
{% endif %} -
+ + + {% for choice in imported_choices %} + + + + + {% endfor %} + {% if draft.is_filing_into_found_case %}{% endif %} + +
{{ choice.label }} + {% if choice.hint %}

From your interview: {{ choice.hint }}

{% endif %} + {% if choice.value %}

Saved court choice: {{ choice.value }}

{% else %}

Needs a court choice

{% endif %} +
Case number{{ draft.docket_number }}
Edit case details

People

-
    - {% for party in parties %} -
  • - {{ party }} - {% if party.party_role_hint %}
    Role from your interview: {{ party.party_role_hint }}{% endif %} - {% if party.party_type or party.party_role_hint %} -
    - Saved court role: {{ party.party_type_name|default:"Needs a court choice" }} - {% endif %} + {% for party in parties %} +

    {{ party }}

    + + + {% if party.party_role_hint %}{% endif %} + {% if party.party_type or party.party_role_hint %}{% endif %} {% if party.address_line_1 %} -
    - {{ party.address_line_1 }} - {% if party.address_line_2 %}, {{ party.address_line_2 }}{% endif %} -
    - {{ party.city }}, {{ party.state }} {{ party.zip_code }} + + + + {% endif %} - {% if party.email %}
    {{ party.email }}{% endif %} - - {% endfor %} - + {% if party.email %}{% endif %} + +
    Interview role{{ party.party_role_hint }}
    Court role{{ party.party_type_name|default:"Needs a court choice" }}
    Address + {{ party.address_line_1 }} + {% if party.address_line_2 %}
    {{ party.address_line_2 }}{% endif %} +
    + {{ party.city }}, {{ party.state }} {{ party.zip_code }} +
    Email{{ party.email }}
    + {% endfor %} Edit people

    Filing types

    -
      - {% for document in documents %} -
    • - {{ document.name }} - {% if document.interview_filing_hint %}
      From your interview: {{ document.interview_filing_hint }}{% endif %} -
      - Saved court choice: {{ document.filing_type_name|default:"Needs a court choice" }} -
    • - {% endfor %} -
    + + + {% for document in documents %} + + + + + {% endfor %} + +
    {{ document.name }} + {% if document.interview_filing_hint %}

    From your interview: {{ document.interview_filing_hint }}

    {% endif %} +

    Saved court choice: {{ document.filing_type_name|default:"Needs a court choice" }}

    +
    Edit documents and filing types
    {% csrf_token %} @@ -110,6 +118,7 @@

    Documents

    {% block extra_css %} + {% endblock extra_css %} {% block extra_js %} From 0a10c552b5982f30e410807a572aa37c150fccdc Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 23 Oct 2026 10:56:39 -0400 Subject: [PATCH 4/6] Offer scoped code lookup for unresolved interview choices --- .../efile/services/filing_code_search.py | 3 + .../efile/services/handoff_replacements.py | 48 ++++++ .../templates/efile/handoff_replacements.html | 58 ++++++++ .../efile/templates/efile/handoff_review.html | 19 +++ .../efile/tests/test_filing_code_search.py | 9 ++ efile_app/efile/tests/test_handoff.py | 137 ++++++++++++++++++ efile_app/efile/urls.py | 6 + efile_app/efile/views/handoff.py | 7 + efile_app/efile/views/handoff_replacements.py | 110 ++++++++++++++ 9 files changed, 397 insertions(+) create mode 100644 efile_app/efile/services/handoff_replacements.py create mode 100644 efile_app/efile/templates/efile/handoff_replacements.html create mode 100644 efile_app/efile/views/handoff_replacements.py diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index 7b7fdaca..04c5e9a6 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -920,6 +920,7 @@ def search_paths( courts=(), category=None, case_type=None, + filing_type=None, include_total=True, include_details=True, ): @@ -935,6 +936,8 @@ def search_paths( paths = paths.filter(case_category__code=category) if case_type: paths = paths.filter(case_type__code=case_type) + if filing_type: + paths = paths.filter(filing_type__code=filing_type) if contexts is not None: paths = paths.filter(context_filter(contexts)) total = paths.count() if include_total else None diff --git a/efile_app/efile/services/handoff_replacements.py b/efile_app/efile/services/handoff_replacements.py new file mode 100644 index 00000000..ce736512 --- /dev/null +++ b/efile_app/efile/services/handoff_replacements.py @@ -0,0 +1,48 @@ +"""Explore indexed filing paths while preserving every saved classification.""" + +from django.db import transaction + +from efile.models import FilingDraft +from efile.services.filing_code_search import validate_path +from efile.services.handoff import HandoffError, record +from efile.workflow import ExistingCase + +CASE_FIELDS = ("court", "case_category", "case_type") + + +def locked_choices(draft, document): + return { + **{facet: getattr(draft, f"{facet}_code") for facet in CASE_FIELDS}, + "filing_type": document.filing_type_code, + } + + +def compatible(draft, document, path): + return path["initial"] == (draft.existing_case == ExistingCase.NEW) and all( + not code or str(path[facet]["code"]) == code for facet, code in locked_choices(draft, document).items() + ) + + +def apply_replacement(draft, document, path): + """Validate live first, then recheck locked values before filling only blanks.""" + validated = validate_path(path) + with transaction.atomic(): + current = FilingDraft.objects.select_for_update().get(pk=draft.pk, user=draft.user) + target = current.documents.select_for_update().get(pk=document.pk) + if current.status != FilingDraft.Status.DRAFT or current.deletion_pending: + raise HandoffError("This draft is no longer editable.", status=409) + if not compatible(current, target, validated): + raise HandoffError("Your saved choices changed. Search again; nothing was replaced.", status=409) + changed = False + for facet in (*CASE_FIELDS, "filing_type"): + obj = target if facet == "filing_type" else current + field = f"{facet}_code" + if getattr(obj, field): + continue + setattr(obj, field, str(validated[facet]["code"])) + setattr(obj, f"{facet}_name", validated[facet]["name"]) + obj.save(update_fields=[field, f"{facet}_name", "updated_at"]) + event_path = f"documents.{target.pk}.{field}" if obj is target else field + record(current, event_path, "user_confirmation", validated[facet]) + changed = True + return changed diff --git a/efile_app/efile/templates/efile/handoff_replacements.html b/efile_app/efile/templates/efile/handoff_replacements.html new file mode 100644 index 00000000..be2701ee --- /dev/null +++ b/efile_app/efile/templates/efile/handoff_replacements.html @@ -0,0 +1,58 @@ +{% extends "efile/site_base.html" %} +{% load static %} +{% block title %}Explore replacement choices{% endblock title %} +{% block public_content %} +

    Explore replacement choices

    + {% if error %}{% endif %} + {% if draft %} +

    Find an available court choice for {{ document.name }}. Your answers and PDFs stay saved.

    +

    Back to your saved filing

    + {% if saved %} +

    Keeping these choices

    + + + {% for choice in saved %}{% endfor %} + +
    {{ choice.label }}{{ choice.value }}
    + {% endif %} + + + + + + {% if not lookup_available %} +

    The code lookup is not available right now. Your filing is saved. You can return to it and choose from the court lists, or try the lookup later.

    + {% elif results %} +

    Possible replacements

    +

    These suggestions come from the code lookup. Choose an option to check it against the current court list and fill only the missing choices.

    + {% for result in results %} +
    +

    Option {{ forloop.counter }}

    + + + {% for choice in result.changes %}{% endfor %} + +
    {{ choice.label }}{{ choice.value }}
    + {% if result.explanation %}

    {{ result.explanation }}

    {% endif %} +
    + {% csrf_token %} + + +
    +
    + {% endfor %} + {% if total > 20 %}

    Showing up to 20 options. Add more detail to your search to narrow the list.

    {% endif %} + {% else %} +

    No replacement was found that keeps your saved choices. Try different search words, or return to your filing to explore the court lists. You do not need to start over.

    + {% endif %} + {% endif %} +{% endblock public_content %} +{% block extra_css %} + +{% endblock extra_css %} diff --git a/efile_app/efile/templates/efile/handoff_review.html b/efile_app/efile/templates/efile/handoff_review.html index 078a9aae..735de6cd 100644 --- a/efile_app/efile/templates/efile/handoff_review.html +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -23,6 +23,12 @@

    Before you continue

    We only need the details listed here. Your other answers are already saved.

    Complete missing details

    + {% if replacement_documents %} +

    Could not match a court choice? Explore replacements while keeping your saved choices.

    + {% for document in replacement_documents %} +

    Explore replacements for {{ document.name }}

    + {% endfor %} + {% endif %} {% else %} {% if needs_document_review %}

    Open your PDFs below and check the copies the court will receive. You can edit your answers if anything needs to change.

    @@ -96,6 +102,19 @@

    Filing types

    {% endfor %} + {% if replacement_documents %} + + + {% for document in replacement_documents %} + + + + + {% endfor %} + +
    Possible replacement for {{ document.name }}Explore available choices
    + {% endif %} Edit documents and filing types
    {% csrf_token %} diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index aadc779e..f505b45c 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -1490,3 +1490,12 @@ def test_captured_vermont_paths_are_discoverable_in_each_audited_court(catalog): assert found["total"] == expected_count assert {row["filing_type"]["code"] for row in found["results"]} == {expected_code} assert {row["court"]["code"] for row in found["results"]} == {court_code} + + +def test_replacement_search_filters_all_saved_codes_before_pagination(index): + result = search_paths( + index, "complaint", court="housing", category="civil", case_type="sp", filing_type="spcomplaint", limit=1 + ) + assert result["total"] == 1 + assert result["results"][0]["filing_type"]["code"] == "spcomplaint" + assert search_paths(index, "complaint", court="housing", case_type="sp", filing_type="complaint")["total"] == 0 diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index d280202e..952f1050 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -891,3 +891,140 @@ def test_imported_hints_and_unmatched_warning_outside_accordion(client, source, assert "the interview suggested “Relief from abuse”" not in before assert "Saved court choice: Relief from abuse (current court label)" in inside assert "From your interview: Relief from abuse" in inside + + +@pytest.fixture +def replacement_draft(client, source, payload, storage, django_user_model): + from django.utils import timezone + + from efile.models import FilingCodeIndex, FilingCodePath + + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.court_code, draft.court_name = "kept-court", "Saved court name" + draft.case_category_code, draft.case_category_name = "kept-category", "Saved category name" + draft.save() + index = FilingCodeIndex.objects.create( + jurisdiction="vermont", source_url="https://example.org", refreshed_at=timezone.now() + ) + values = { + "initial": True, + "court": {"code": "kept-court", "name": "Current court label"}, + "case_category": {"code": "kept-category", "name": "Current category label"}, + "case_type": {"code": "replacement-case", "name": "Protection from abuse"}, + "filing_type": {"code": "replacement-filing", "name": "Complaint for protection"}, + } + path = FilingCodePath.objects.create(index=index, **values) + document = draft.documents.get() + return draft, document, index, path, values, reverse("handoff_replacements", args=[draft.pk, document.pk]) + + +def test_replacement_fills_only_missing_choices(client, replacement_draft): + draft, document, index, path, values, url = replacement_draft + before_party = list(draft.parties.values()) + document.document_type_code = "keep-private" + document.save() + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.services.handoff_replacements.validate_path", return_value=values), + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == 302 + draft.refresh_from_db() + document.refresh_from_db() + assert (draft.court_code, draft.court_name) == ("kept-court", "Saved court name") + assert (draft.case_category_code, draft.case_category_name) == ("kept-category", "Saved category name") + assert draft.case_type_code == "replacement-case" + assert document.filing_type_code == "replacement-filing" + assert document.document_type_code == "keep-private" + assert document.s3_key == "private/key.pdf" + assert list(draft.parties.values()) == before_party + + +@pytest.mark.parametrize("failure", ["wrong_court", "stale", "network", "concurrent_choice"]) +def test_replacement_failure_preserves_draft(client, replacement_draft, failure): + import requests + + draft, document, index, path, values, url = replacement_draft + if failure == "wrong_court": + path.court = {"code": "different", "name": "Different court"} + path.save() + + def validate(_): + if failure == "stale": + raise ValueError("This choice is no longer available.") + if failure == "network": + raise requests.Timeout() + if failure == "concurrent_choice": + FilingDraft.objects.filter(pk=draft.pk).update(case_type_code="chosen-in-another-tab") + return values + + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.services.handoff_replacements.validate_path", side_effect=validate), + patch("efile.views.handoff_replacements.search_paths", return_value={"results": [], "total": 0}), + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == (503 if failure == "network" else 409) + draft.refresh_from_db() + document.refresh_from_db() + assert draft.court_code == "kept-court" + assert draft.case_type_code == ("chosen-in-another-tab" if failure == "concurrent_choice" else "") + assert document.filing_type_code == "" + assert document.s3_key == "private/key.pdf" + assert b"Back to your saved filing" in response.content + + +def test_replacement_search_keeps_saved_filters(client, replacement_draft): + draft, document, index, _, _, url = replacement_draft + document.filing_type_code = "keep-filing" + document.save() + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.views.handoff_replacements.search_paths", return_value={"results": [], "total": 0}) as search, + ): + response = client.get(url, {"q": "protection"}) + assert response.status_code == 200 + assert b"You do not need to start over" in response.content + assert search.call_args.kwargs["court"] == "kept-court" + assert search.call_args.kwargs["category"] == "kept-category" + assert search.call_args.kwargs["filing_type"] == "keep-filing" + draft.refresh_from_db() + assert draft.case_type_code == "" + + +def test_replacement_rejects_other_owners(client, replacement_draft, django_user_model): + *_, url = replacement_draft + login(client, django_user_model, username="another-filer") + assert client.get(url).status_code == 404 + assert client.post(url, {"path_id": 1}).status_code == 404 + + +def test_missing_lookup_keeps_a_route_back_to_saved_draft(client, replacement_draft): + draft, document, _, _, _, url = replacement_draft + with patch("efile.views.handoff_replacements.current_index", return_value=None): + response = client.get(url) + assert response.status_code == 200 + assert b"code lookup is not available right now" in response.content + assert b"Back to your saved filing" in response.content + draft.refresh_from_db() + document.refresh_from_db() + assert draft.case_type_code == "" + assert document.s3_key == "private/key.pdf" + + +def test_replacement_cannot_change_case_stage(client, replacement_draft): + draft, _, index, path, _, url = replacement_draft + path.initial = False + path.save() + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.views.handoff_replacements.search_paths", return_value={"results": [], "total": 0}), + patch("efile.services.handoff_replacements.validate_path") as validate, + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == 409 + validate.assert_not_called() + draft.refresh_from_db() + assert draft.case_type_code == "" diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index e3303a00..18ba2cd9 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -30,6 +30,7 @@ replace_documents, return_to_interview, ) +from .views.handoff_replacements import handoff_replacements from .views.legacy_workflow import legacy_workflow_redirect from .views.login import efile_login, efile_logout, efile_password_reset from .views.my_cases import filing_detail, filing_statuses @@ -75,6 +76,11 @@ def jurisdiction_homepage(request, jurisdiction): path("api/handoffs/v1/documents/", replace_documents, name="handoff_replace_documents"), path("handoff/claim//", handoff_claim, name="handoff_claim"), path("handoff/drafts//", handoff_review, name="handoff_review"), + path( + "handoff/drafts//replacements//", + handoff_replacements, + name="handoff_replacements", + ), path("handoff/drafts//correct/", correct_filing, name="correct_filing"), path("handoff/drafts//interview/", return_to_interview, name="return_to_interview"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index b88560c6..72c5f1aa 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -312,6 +312,13 @@ def handoff_review(request, draft_id): "draft": draft, "issues": issues, "imported_choices": imported_choices, + "replacement_documents": [ + document + for document in documents + if not all( + (draft.court_code, draft.case_category_code, draft.case_type_code, document.filing_type_code) + ) + ], "receipt": receipt, "documents": documents, "preview_fingerprint": preview_fingerprint(documents), diff --git a/efile_app/efile/views/handoff_replacements.py b/efile_app/efile/views/handoff_replacements.py new file mode 100644 index 00000000..c0c1767d --- /dev/null +++ b/efile_app/efile/views/handoff_replacements.py @@ -0,0 +1,110 @@ +"""Focused code lookup for an imported draft; existing selections stay fixed.""" + +import requests +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_http_methods + +from efile.services.filing_code_search import current_index, search_paths +from efile.services.handoff import HandoffError, effective_hints, receipt_for +from efile.services.handoff_replacements import apply_replacement, compatible, locked_choices +from efile.views.handoff import _owned, _private +from efile.workflow import ExistingCase + +CHOICE_LABELS = ( + ("court", "Court"), + ("case_category", "Case category"), + ("case_type", "Case type"), + ("filing_type", "Filing type"), +) + + +@require_http_methods(["GET", "POST"]) +def handoff_replacements(request, draft_id, document_id): + try: + draft = _owned(request, draft_id) + except HandoffError as exc: + return _private(render(request, "efile/handoff_replacements.html", {"error": str(exc)}, status=exc.status)) + document = get_object_or_404(draft.documents, pk=document_id) + if draft.status != "draft" or draft.existing_case not in (ExistingCase.NEW, ExistingCase.EXISTING): + return redirect("handoff_review", draft_id=draft.pk) + receipt = receipt_for(draft) + if not receipt: + return redirect("handoff_review", draft_id=draft.pk) + if all(locked_choices(draft, document).values()): + return redirect("handoff_review", draft_id=draft.pk) + suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() + hints = effective_hints( + receipt.payload, court_name=draft.court_name, document=suggestion.value if suggestion else {} + ) + default_query = " ".join(hints.get("filing_type_name_hints", []) or [document.name])[:160] + query = request.GET.get("q", default_query).strip()[:160] + index = current_index(draft.jurisdiction) + error = "" + status = 200 + results = [] + total = 0 + if request.method == "POST": + try: + submitted_id = request.POST.get("path_id", "") + if not submitted_id.isascii() or not submitted_id.isdecimal() or len(submitted_id) > 18: + raise HandoffError("Choose an available replacement.", status=400) + path_id = int(submitted_id) + path = ( + index.paths.filter(pk=path_id, initial=draft.existing_case == ExistingCase.NEW).first() + if index + else None + ) + if path is None: + raise HandoffError("This option is no longer in the lookup. Search again.", status=409) + if not compatible( + draft, + document, + {**{key: getattr(path, key) for key in locked_choices(draft, document)}, "initial": path.initial}, + ): + raise HandoffError("This option would change a saved choice. Nothing was changed.", status=409) + apply_replacement(draft, document, path) + return redirect("handoff_review", draft_id=draft.pk) + except (ValueError, HandoffError) as exc: + error, status = str(exc), 409 + except requests.RequestException: + error, status = "The court list could not be checked. Your draft is saved; try again later.", 503 + if index and query: + result = search_paths( + index, + query, + initial=draft.existing_case == ExistingCase.NEW, + court=draft.court_code, + category=draft.case_category_code, + case_type=draft.case_type_code, + filing_type=document.filing_type_code, + ) + results = [item for item in result["results"] if not item["unavailable"]] + total = result["total"] + saved = [ + {"label": label, "value": getattr(draft if facet != "filing_type" else document, f"{facet}_name")} + for facet, label in CHOICE_LABELS + if locked_choices(draft, document)[facet] + ] + for result in results: + result["changes"] = [ + {"label": label, "value": result[facet]["name"]} + for facet, label in CHOICE_LABELS + if not locked_choices(draft, document)[facet] + ] + return _private( + render( + request, + "efile/handoff_replacements.html", + { + "draft": draft, + "document": document, + "query": query, + "results": results, + "saved": saved, + "error": error, + "lookup_available": index is not None, + "total": total, + }, + status=status, + ) + ) From c0de5672c6b453cfaaf035e2203d2add49036c1d Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 23 Oct 2026 11:36:23 -0400 Subject: [PATCH 5/6] Address independent handoff and replacement lookup reviews --- docs/developer-notes/issue-166-handoff-ux.md | 16 ++ .../efile/services/filing_code_search.py | 2 + efile_app/efile/services/handoff.py | 19 ++- .../efile/services/handoff_replacements.py | 39 ++++- .../templates/efile/handoff_replacements.html | 94 +++++------ .../efile/templates/efile/handoff_review.html | 16 +- .../efile/tests/test_filing_code_search.py | 12 ++ efile_app/efile/tests/test_handoff.py | 148 +++++++++++++++++- efile_app/efile/views/handoff.py | 48 +++++- efile_app/efile/views/handoff_replacements.py | 53 ++++++- 10 files changed, 379 insertions(+), 68 deletions(-) diff --git a/docs/developer-notes/issue-166-handoff-ux.md b/docs/developer-notes/issue-166-handoff-ux.md index 0b840c74..b0e9666f 100644 --- a/docs/developer-notes/issue-166-handoff-ux.md +++ b/docs/developer-notes/issue-166-handoff-ux.md @@ -33,3 +33,19 @@ Both identified the duplicate case-confirmation loop, buried actions, outdated s - Parsed the RFA YAML and rendered its introduction/download Mako templates with synthetic helpers in both enabled and disabled modes. Confirmed the enabled mode omits the contradictory manual-only instructions and puts the filing action before downloads. - Chromium at 390 × 844: ready/missing handoff primary actions are above the fold; the saved-details disclosure opens and closes with Enter. [Synthetic screenshots](https://gist.github.com/nonprofittechy/841e3dd9efb519a3cc91e481aa1f1a40). - These checks do not establish a successful Vermont test filing. Full Docassemble execution, test-EFM submission, court receipt/status, and Vermont partner acceptance remain outstanding under issue 166. + +## Independent code review and follow-up + +Claude and agy/Argon reviewed the full implementation through `0a10c55` independently. Their raw reports are retained in the unlisted evidence gist; some report line numbers and symbol names were inaccurate, so findings were checked against the source and regression tests. + +Confirmed issues addressed in the follow-up: + +- Restrict the focused replacement lookup to new cases. Existing-case drafts keep the court-record lookup and case-specific permitted filing types; drafts with an undecided case stage no longer show unusable replacement links. +- Resume matching of dependent interview hints after a successful replacement. Keep metadata matching out of PDF approval actions so metadata-only timestamp changes cannot invalidate the displayed fingerprint. +- Approve the displayed PDF copies when continuing to missing details, avoiding duplicate review at payment; stale fingerprints still block approval. +- Reset blank confidentiality confirmation when its filing-type scope changes, including clerk corrections. Carry the live amount-in-controversy requirement into document metadata. +- Preserve all saved codes, refresh conflict results from current records, and recover when a document disappears during live validation. Write one provenance event per selected replacement. +- Seed lookup from one relevant hint rather than combining alternative names into an AND query; preserve the lead document's top-level hint fallback. Surface unmatched filing and party-role hints outside the accordion. +- Display unavailable results with their explanation and no selection control, with a link to later result pages. + +Validation uses synthetic court responses. It does not establish a live Vermont EFM acceptance test. The focused replacement route does not replace the existing party-role or confidentiality editors. diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index 04c5e9a6..0bbed6cf 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -426,6 +426,8 @@ def get(self, suffix="", **params): if not code: raise ValueError("Court code API returned an empty code") result[code] = {"code": code, "name": str(item["name"])} + if "amountincontroversy" in item: + result[code]["amountincontroversy"] = item["amountincontroversy"] return list(result.values()) def courts(self): diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index 095eac31..d8f9954b 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -380,7 +380,16 @@ def choose(obj, field, options, hints): name_field = field.replace("_code", "_name") if field != "party_type" else "party_type_name" setattr(obj, name_field, str(option.get("name", ""))) obj._metadata_kind = "live_resolution" - obj.save(update_fields=[field, name_field, "updated_at"]) + update_fields = [field, name_field, "updated_at"] + if field == "filing_type_code" and isinstance(obj, FilingDocument): + obj.filing_requires_amount_in_controversy = ( + str(option.get("amountincontroversy", "")).lower() == "required" + ) + update_fields.append("filing_requires_amount_in_controversy") + if not obj.document_type_code: + obj.document_type_confirmed = False + update_fields.append("document_type_confirmed") + obj.save(update_fields=update_fields) del obj._metadata_kind record( draft, @@ -524,7 +533,7 @@ def need(path, value, message, view): # payload checks, not a mandatory question for every filer. if field == "filing_component_code": continue - if field == "document_type_code" and doc.document_type_confirmed: + if field == "document_type_code" and doc.filing_type_code and doc.document_type_confirmed: continue need( path, @@ -675,6 +684,10 @@ def create_correction(draft, detail, fields=()): obj.filing_component_code = "" obj.filing_component_name = "" obj.requested_optional_services = [] + if parts[0] == "documents" and parts[2] in ("filing_type_code", "document_type_code"): + obj.document_type_confirmed = False + if parts[2] == "filing_type_code": + obj.filing_requires_amount_in_controversy = False obj.save() elif path != "documents": setattr(revision, path, "") @@ -693,6 +706,8 @@ def create_correction(draft, detail, fields=()): setattr(document, f"{field}_code", "") setattr(document, f"{field}_name", "") document.requested_optional_services = [] + document.document_type_confirmed = False + document.filing_requires_amount_in_controversy = False document.save() for party in revision.parties.all(): party._metadata_kind = "clerk_correction" diff --git a/efile_app/efile/services/handoff_replacements.py b/efile_app/efile/services/handoff_replacements.py index ce736512..584733d6 100644 --- a/efile_app/efile/services/handoff_replacements.py +++ b/efile_app/efile/services/handoff_replacements.py @@ -2,7 +2,7 @@ from django.db import transaction -from efile.models import FilingDraft +from efile.models import FilingDocument, FilingDraft from efile.services.filing_code_search import validate_path from efile.services.handoff import HandoffError, record from efile.workflow import ExistingCase @@ -25,13 +25,24 @@ def compatible(draft, document, path): def apply_replacement(draft, document, path): """Validate live first, then recheck locked values before filling only blanks.""" + if draft.existing_case != ExistingCase.NEW: + raise HandoffError("Use the confirmed case’s filing choices for an existing case.", status=409) validated = validate_path(path) with transaction.atomic(): - current = FilingDraft.objects.select_for_update().get(pk=draft.pk, user=draft.user) - target = current.documents.select_for_update().get(pk=document.pk) + try: + current = FilingDraft.objects.select_for_update().get(pk=draft.pk, user=draft.user) + target = current.documents.select_for_update().get(pk=document.pk) + except (FilingDraft.DoesNotExist, FilingDocument.DoesNotExist) as exc: + raise HandoffError( + "Your documents changed. Return to your saved filing and try again.", status=409 + ) from exc if current.status != FilingDraft.Status.DRAFT or current.deletion_pending: raise HandoffError("This draft is no longer editable.", status=409) - if not compatible(current, target, validated): + if ( + current.existing_case != ExistingCase.NEW + or locked_choices(current, target) != locked_choices(draft, document) + or not compatible(current, target, validated) + ): raise HandoffError("Your saved choices changed. Search again; nothing was replaced.", status=409) changed = False for facet in (*CASE_FIELDS, "filing_type"): @@ -41,8 +52,24 @@ def apply_replacement(draft, document, path): continue setattr(obj, field, str(validated[facet]["code"])) setattr(obj, f"{facet}_name", validated[facet]["name"]) - obj.save(update_fields=[field, f"{facet}_name", "updated_at"]) + update_fields = [field, f"{facet}_name", "updated_at"] + if obj is target: + target.filing_requires_amount_in_controversy = ( + str(validated[facet].get("amountincontroversy", "")).lower() == "required" + ) + update_fields.append("filing_requires_amount_in_controversy") + if not target.document_type_code: + target.document_type_confirmed = False + update_fields.append("document_type_confirmed") + obj._metadata_kind = "code_search_replacement" + try: + obj.save(update_fields=update_fields) + finally: + del obj._metadata_kind event_path = f"documents.{target.pk}.{field}" if obj is target else field - record(current, event_path, "user_confirmation", validated[facet]) + record(current, event_path, "user_confirmation", {**validated[facet], "source": "code search"}) + if event_path in current.correction_fields: + current.correction_fields.remove(event_path) + FilingDraft.objects.filter(pk=current.pk).update(correction_fields=current.correction_fields) changed = True return changed diff --git a/efile_app/efile/templates/efile/handoff_replacements.html b/efile_app/efile/templates/efile/handoff_replacements.html index be2701ee..840359be 100644 --- a/efile_app/efile/templates/efile/handoff_replacements.html +++ b/efile_app/efile/templates/efile/handoff_replacements.html @@ -5,51 +5,57 @@

    Explore replacement choices

    {% if error %}{% endif %} {% if draft %} -

    Find an available court choice for {{ document.name }}. Your answers and PDFs stay saved.

    + {% if document %}

    Find an available court choice for {{ document.name }}. Your answers and PDFs stay saved.

    {% endif %}

    Back to your saved filing

    - {% if saved %} -

    Keeping these choices

    - - - {% for choice in saved %}{% endfor %} - -
    {{ choice.label }}{{ choice.value }}
    - {% endif %} - - - - - - {% if not lookup_available %} -

    The code lookup is not available right now. Your filing is saved. You can return to it and choose from the court lists, or try the lookup later.

    - {% elif results %} -

    Possible replacements

    -

    These suggestions come from the code lookup. Choose an option to check it against the current court list and fill only the missing choices.

    - {% for result in results %} -
    -

    Option {{ forloop.counter }}

    - - - {% for choice in result.changes %}{% endfor %} - -
    {{ choice.label }}{{ choice.value }}
    - {% if result.explanation %}

    {{ result.explanation }}

    {% endif %} -
    - {% csrf_token %} - - -
    -
    - {% endfor %} - {% if total > 20 %}

    Showing up to 20 options. Add more detail to your search to narrow the list.

    {% endif %} - {% else %} -

    No replacement was found that keeps your saved choices. Try different search words, or return to your filing to explore the court lists. You do not need to start over.

    + {% if not recovery_only %} + {% if saved %} +

    Keeping these choices

    + + + {% for choice in saved %}{% endfor %} + +
    {{ choice.label }}{{ choice.value }}
    + {% endif %} +
    + + + +
    + {% if not lookup_available %} +

    The code lookup is not available right now. Your filing is saved. You can return to it and choose from the court lists, or try the lookup later.

    + {% elif results %} +

    Possible replacements

    +

    These suggestions come from the code lookup. Choose an option to check it against the current court list and fill only the missing choices.

    + {% for result in results %} +
    +

    Option {{ forloop.counter }}

    + + + {% for choice in result.changes %}{% endfor %} + +
    {{ choice.label }}{{ choice.value }}
    + {% if result.explanation %}

    {{ result.explanation }}

    {% endif %} + {% if result.unavailable %} +

    {{ result.unavailable }}

    + {% else %} +
    + {% csrf_token %} + + +
    + {% endif %} +
    + {% endfor %} + {% if next_results %}

    More possible replacements

    {% endif %} + {% else %} +

    No replacement was found that keeps your saved choices. Try different search words, or return to your filing to explore the court lists. You do not need to start over.

    + {% endif %} {% endif %} {% endif %} {% endblock public_content %} diff --git a/efile_app/efile/templates/efile/handoff_review.html b/efile_app/efile/templates/efile/handoff_review.html index 735de6cd..09f6c4bf 100644 --- a/efile_app/efile/templates/efile/handoff_review.html +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -21,7 +21,21 @@

    {% if issues %}

    Before you continue

    We only need the details listed here. Your other answers are already saved.

    -

    Complete missing details

    + {% if can_review_documents %} + {% if needs_document_review %}

    Open your PDFs below and check the copies the court will receive before continuing.

    {% endif %} +
    + {% csrf_token %} + + +
    + {% else %} +

    Complete missing details

    + {% endif %}
    {% if replacement_documents %}

    Could not match a court choice? Explore replacements while keeping your saved choices.

    diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index f505b45c..6a016fad 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -1499,3 +1499,15 @@ def test_replacement_search_filters_all_saved_codes_before_pagination(index): assert result["total"] == 1 assert result["results"][0]["filing_type"]["code"] == "spcomplaint" assert search_paths(index, "complaint", court="housing", case_type="sp", filing_type="complaint")["total"] == 0 + + +def test_live_catalog_preserves_amount_requirement(): + api = CodeCatalog("vermont") + response = Mock() + response.json.return_value = [{"code": "claim", "name": "Claim", "amountincontroversy": "Required"}] + try: + with patch.object(api.session, "get", return_value=response): + choices = api.get("court/filing_types/") + assert choices[0]["amountincontroversy"] == "Required" + finally: + api.close() diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index 952f1050..b8f970a6 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -917,7 +917,8 @@ def replacement_draft(client, source, payload, storage, django_user_model): } path = FilingCodePath.objects.create(index=index, **values) document = draft.documents.get() - return draft, document, index, path, values, reverse("handoff_replacements", args=[draft.pk, document.pk]) + with patch("efile.services.handoff._codes", return_value=[]): + yield draft, document, index, path, values, reverse("handoff_replacements", args=[draft.pk, document.pk]) def test_replacement_fills_only_missing_choices(client, replacement_draft): @@ -1028,3 +1029,148 @@ def test_replacement_cannot_change_case_stage(client, replacement_draft): validate.assert_not_called() draft.refresh_from_db() assert draft.case_type_code == "" + + +@pytest.mark.parametrize("stage", ["yes", "", "unsure"]) +def test_replacement_is_only_available_for_new_cases(client, replacement_draft, stage): + draft, _, index, path, _, url = replacement_draft + draft.existing_case = stage + draft.save() + with patch("efile.views.handoff_replacements.current_index", return_value=index): + assert client.get(url).status_code == 302 + assert client.post(url, {"path_id": path.pk}).status_code == 302 + page = client.get(reverse("handoff_review", args=[draft.pk])) + assert b"Explore replacements for" not in page.content + + +def test_replacement_resumes_dependent_matching_and_records_once(client, replacement_draft): + draft, document, index, path, values, url = replacement_draft + values["filing_type"]["amountincontroversy"] = "Required" + document.document_type_confirmed = True + document.save() + event_count = draft.metadata_events.count() + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.services.handoff_replacements.validate_path", return_value=values), + patch("efile.services.handoff._codes", return_value=[{"code": "plaintiff", "name": "plaintiff"}]), + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == 302 + assert draft.parties.get().party_type == "plaintiff" + document.refresh_from_db() + assert document.filing_requires_amount_in_controversy + assert not document.document_type_confirmed + events = list(draft.metadata_events.all())[event_count:] + assert [event.kind for event in events].count("user_edit") == 0 + assert len([event for event in events if event.path == f"documents.{document.pk}.filing_type_code"]) == 1 + + +def test_document_removed_during_validation_is_recoverable(client, replacement_draft): + draft, document, index, path, values, url = replacement_draft + + def validate(_): + document.delete() + return values + + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.services.handoff_replacements.validate_path", side_effect=validate), + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == 409 + assert b"Your documents changed" in response.content + assert b"Back to your saved filing" in response.content + draft.refresh_from_db() + assert draft.case_type_code == "" + + +def test_replacement_default_uses_first_top_level_hint(client, replacement_draft): + draft, _, index, _, _, url = replacement_draft + receipt = draft.handoff + receipt.payload["filing_type_name_hints"] = ["Complaint", "Protection petition"] + receipt.save() + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.views.handoff_replacements.search_paths", return_value={"results": [], "total": 0}) as search, + ): + client.get(url) + assert search.call_args.args[1] == "Complaint" + + +def test_continue_does_not_resolve_metadata_after_displaying_pdf_fingerprint(client, replacement_draft): + from efile.services.document_previews import preview_fingerprint + + draft, document, *_ = replacement_draft + document.preparation = "unchanged" + document.save() + with ( + patch("efile.views.handoff.resolve_metadata") as resolve, + patch("efile.views.handoff.issues_for", return_value=[]), + ): + response = client.post( + reverse("handoff_review", args=[draft.pk]), + {"action": "continue", "preview_fingerprint": preview_fingerprint([document])}, + ) + assert response.status_code == 302 + resolve.assert_not_called() + document.refresh_from_db() + assert document.preparation_reviewed_at is not None + + +def test_complete_missing_details_approves_current_pdfs_once(client, replacement_draft): + from efile.services.document_previews import preview_fingerprint + + draft, document, *_ = replacement_draft + document.preparation = "unchanged" + document.save() + response = client.post( + reverse("handoff_review", args=[draft.pk]), + {"action": "complete_details", "preview_fingerprint": preview_fingerprint([document])}, + ) + assert response.status_code == 302 + assert "return_to=handoff" in response.url + document.refresh_from_db() + assert document.preparation_reviewed_at is not None + + +def test_correction_resets_confidentiality_confirmation(client, replacement_draft): + draft, document, *_ = replacement_draft + document.document_type_confirmed = True + document.filing_requires_amount_in_controversy = True + document.save() + draft.status = FilingDraft.Status.SUBMITTED + draft.save() + revision = create_correction(draft, {"status": "returned"}, [f"documents.{document.pk}.filing_type_code"]) + corrected = revision.documents.get() + assert not corrected.document_type_confirmed + assert not corrected.filing_requires_amount_in_controversy + + +def test_complete_details_rejects_stale_pdf_review(client, replacement_draft): + draft, document, *_ = replacement_draft + document.preparation = "unchanged" + document.save() + response = client.post( + reverse("handoff_review", args=[draft.pk]), {"action": "complete_details", "preview_fingerprint": "stale"} + ) + assert response.status_code == 422 + document.refresh_from_db() + assert document.preparation_reviewed_at is None + + +def test_conflict_page_uses_current_saved_choices(client, replacement_draft): + draft, _, index, path, values, url = replacement_draft + + def validate(_): + FilingDraft.objects.filter(pk=draft.pk).update(case_type_code="newer-choice", case_type_name="Newer choice") + return values + + with ( + patch("efile.views.handoff_replacements.current_index", return_value=index), + patch("efile.services.handoff_replacements.validate_path", side_effect=validate), + patch("efile.views.handoff_replacements.search_paths", return_value={"results": [], "total": 0}) as search, + ): + response = client.post(url, {"path_id": path.pk}) + assert response.status_code == 409 + assert b"Newer choice" in response.content + assert search.call_args.kwargs["case_type"] == "newer-choice" diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 72c5f1aa..3d5b3bfd 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -47,7 +47,7 @@ validate_payload, ) from efile.utils.s3_upload_handler import S3UploadHandler -from efile.workflow import RETURN_TO_HANDOFF, WorkflowStepKey, with_return_to +from efile.workflow import RETURN_TO_HANDOFF, ExistingCase, WorkflowStepKey, with_return_to CLAIM_SALT = "litefile.handoff.claim.v1" REPLACE_SALT = "litefile.handoff.replace.v1" @@ -231,7 +231,8 @@ def handoff_review(request, draft_id): review_error = "" status = 200 if request.method == "POST": - resolve_metadata(draft) + if request.POST.get("action") in ("resolve", "confirm"): + resolve_metadata(draft) if request.POST.get("action") == "confirm": # Record the explicit review of existing selections. An unresolved # correction is kept open until its field actually has a value. @@ -250,7 +251,15 @@ def handoff_review(request, draft_id): draft.correction_fields = remaining draft.save(update_fields=["correction_fields", "updated_at"]) record(draft, "review", "user_confirmation", {"user_id": request.user.pk}) - if request.POST.get("action") == "continue": + if request.POST.get("action") == "complete_details": + try: + approve_document_review(draft, request.POST.get("preview_fingerprint")) + except DocumentReviewError as exc: + review_error, status = str(exc), exc.status + else: + remaining = _issue_links(draft) + return redirect(remaining[0]["url"] if remaining else reverse("handoff_review", args=[draft.pk])) + elif request.POST.get("action") == "continue": if issues_for(draft): return redirect("handoff_review", draft_id=draft.pk) try: @@ -273,18 +282,18 @@ def handoff_review(request, draft_id): imported_choices = [ { "label": "Court", - "hint": payload.get("case", {}).get("court_name", ""), - "value": draft.court_name if draft.court_code else "", + "hint": payload.get("case", {}).get("court_name") or payload.get("case", {}).get("county", ""), + "value": (draft.court_name or draft.court_code) if draft.court_code else "", }, { "label": "Case category", "hint": ", ".join(hints.get("case_category_name_hints", [])), - "value": draft.case_category_name if draft.case_category_code else "", + "value": (draft.case_category_name or draft.case_category_code) if draft.case_category_code else "", }, { "label": "Case type", "hint": ", ".join(hints.get("case_type_name_hints", [])), - "value": draft.case_type_name if draft.case_type_code else "", + "value": (draft.case_type_name or draft.case_type_code) if draft.case_type_code else "", }, ] for document in documents: @@ -304,6 +313,27 @@ def handoff_review(request, draft_id): f"{choice['label']}: the interview suggested “{choice['hint']}”, " "but no court choice is matched. Choose an available option." ) + unmatched_hints = { + f"documents.{document.pk}.filing_type_code": ( + f"Filing type for {document.name}", + document.interview_filing_hint, + ) + for document in documents + if not document.filing_type_code + } + unmatched_hints.update( + { + f"parties.{party.pk}.party_type": (f"Court role for {party}", party.party_role_hint) + for party in draft.parties.all() + if not party.party_type + } + ) + for issue in issues: + label, hint = unmatched_hints.get(issue["path"], ("", "")) + if hint and issue["path"] not in draft.correction_fields: + issue["message"] = ( + f"{label}: the interview suggested “{hint}”, but no court choice is matched. Choose an available option." + ) return _private( render( request, @@ -315,13 +345,15 @@ def handoff_review(request, draft_id): "replacement_documents": [ document for document in documents - if not all( + if draft.existing_case == ExistingCase.NEW + and not all( (draft.court_code, draft.case_category_code, draft.case_type_code, document.filing_type_code) ) ], "receipt": receipt, "documents": documents, "preview_fingerprint": preview_fingerprint(documents), + "can_review_documents": bool(documents) and all(doc.preparation for doc in documents), "needs_document_review": any(doc.preparation_reviewed_at is None for doc in documents), "review_error": review_error, "parties": draft.parties.all(), diff --git a/efile_app/efile/views/handoff_replacements.py b/efile_app/efile/views/handoff_replacements.py index c0c1767d..d7c0748e 100644 --- a/efile_app/efile/views/handoff_replacements.py +++ b/efile_app/efile/views/handoff_replacements.py @@ -1,11 +1,13 @@ """Focused code lookup for an imported draft; existing selections stay fixed.""" +from urllib.parse import urlencode + import requests from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_http_methods from efile.services.filing_code_search import current_index, search_paths -from efile.services.handoff import HandoffError, effective_hints, receipt_for +from efile.services.handoff import HandoffError, effective_hints, receipt_for, resolve_metadata from efile.services.handoff_replacements import apply_replacement, compatible, locked_choices from efile.views.handoff import _owned, _private from efile.workflow import ExistingCase @@ -25,7 +27,7 @@ def handoff_replacements(request, draft_id, document_id): except HandoffError as exc: return _private(render(request, "efile/handoff_replacements.html", {"error": str(exc)}, status=exc.status)) document = get_object_or_404(draft.documents, pk=document_id) - if draft.status != "draft" or draft.existing_case not in (ExistingCase.NEW, ExistingCase.EXISTING): + if draft.status != "draft" or draft.existing_case != ExistingCase.NEW: return redirect("handoff_review", draft_id=draft.pk) receipt = receipt_for(draft) if not receipt: @@ -36,8 +38,22 @@ def handoff_replacements(request, draft_id, document_id): hints = effective_hints( receipt.payload, court_name=draft.court_name, document=suggestion.value if suggestion else {} ) - default_query = " ".join(hints.get("filing_type_name_hints", []) or [document.name])[:160] + top_hints = effective_hints(receipt.payload, court_name=draft.court_name) + candidates = hints.get("filing_type_name_hints", []) or ( + top_hints.get("filing_type_name_hints", []) if document.role == "lead" else [] + ) + if document.filing_type_code: + candidates = ( + top_hints.get("case_type_name_hints", []) + if not draft.case_type_code + else top_hints.get("case_category_name_hints", []) + ) + default_query = next((hint for hint in candidates if hint.strip()), document.name)[:160] query = request.GET.get("q", default_query).strip()[:160] + try: + offset = max(0, min(int(request.GET.get("offset", "0")), 10000)) + except ValueError: + offset = 0 index = current_index(draft.jurisdiction) error = "" status = 200 @@ -63,25 +79,47 @@ def handoff_replacements(request, draft_id, document_id): ): raise HandoffError("This option would change a saved choice. Nothing was changed.", status=409) apply_replacement(draft, document, path) + draft.refresh_from_db() + resolve_metadata(draft) return redirect("handoff_review", draft_id=draft.pk) except (ValueError, HandoffError) as exc: - error, status = str(exc), 409 + error, status = str(exc), getattr(exc, "status", 409) except requests.RequestException: error, status = "The court list could not be checked. Your draft is saved; try again later.", 503 + if request.method == "POST": + try: + draft = _owned(request, draft_id) + except HandoffError as exc: + return _private(render(request, "efile/handoff_replacements.html", {"error": str(exc)}, status=exc.status)) + document = draft.documents.filter(pk=document_id).first() + if document is None or draft.status != "draft" or draft.existing_case != ExistingCase.NEW: + return _private( + render( + request, + "efile/handoff_replacements.html", + {"draft": draft, "error": error, "recovery_only": True}, + status=status, + ) + ) if index and query: result = search_paths( index, query, + offset=offset, initial=draft.existing_case == ExistingCase.NEW, court=draft.court_code, category=draft.case_category_code, case_type=draft.case_type_code, filing_type=document.filing_type_code, ) - results = [item for item in result["results"] if not item["unavailable"]] + results = result["results"] total = result["total"] saved = [ - {"label": label, "value": getattr(draft if facet != "filing_type" else document, f"{facet}_name")} + { + "label": label, + "value": getattr(draft if facet != "filing_type" else document, f"{facet}_name") + or locked_choices(draft, document)[facet], + } for facet, label in CHOICE_LABELS if locked_choices(draft, document)[facet] ] @@ -104,6 +142,9 @@ def handoff_replacements(request, draft_id, document_id): "error": error, "lookup_available": index is not None, "total": total, + "next_results": "?" + urlencode({"q": query, "offset": offset + 20}) + if offset + 20 < total and offset < 10000 + else "", }, status=status, ) From 2a0dcc427d8e0d13474d24c56e9adc5f0cc5d28a Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Fri, 23 Oct 2026 15:55:52 -0400 Subject: [PATCH 6/6] Simplify handoff hints and replacement lookup helpers --- efile_app/efile/services/handoff.py | 35 +++++-- .../efile/services/handoff_replacements.py | 37 +++---- efile_app/efile/views/handoff.py | 97 ++++++++----------- efile_app/efile/views/handoff_replacements.py | 67 ++++++------- 4 files changed, 111 insertions(+), 125 deletions(-) diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index d8f9954b..70a745f4 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -144,6 +144,33 @@ def effective_hints(payload, *, court_name="", document=None): return result +def interview_filing_hints(draft, payload, documents): + """Each document's interview filing-type hints by pk; the lead falls back to the case's.""" + sources = {} + for event in draft.metadata_events.filter( + kind="source_suggestion", path__in=[f"documents.{document.pk}" for document in documents] + ): + sources.setdefault(event.path, event.value) + case_hints = effective_hints(payload, court_name=draft.court_name).get("filing_type_name_hints", []) + return { + document.pk: effective_hints( + payload, court_name=draft.court_name, document=sources.get(f"documents.{document.pk}", {}) + ).get("filing_type_name_hints", []) + or (case_hints if document.role == "lead" else []) + for document in documents + } + + +def apply_filing_type_flags(document, option): + """Copy the court's per-filing-type flags onto a newly chosen filing type; return fields to save.""" + document.filing_requires_amount_in_controversy = str(option.get("amountincontroversy", "")).lower() == "required" + update_fields = ["filing_requires_amount_in_controversy"] + if not document.document_type_code: + document.document_type_confirmed = False + update_fields.append("document_type_confirmed") + return update_fields + + def validate_payload(payload, source_config, files, *, require_lead=True): _object(payload, "payload") if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1: @@ -382,13 +409,7 @@ def choose(obj, field, options, hints): obj._metadata_kind = "live_resolution" update_fields = [field, name_field, "updated_at"] if field == "filing_type_code" and isinstance(obj, FilingDocument): - obj.filing_requires_amount_in_controversy = ( - str(option.get("amountincontroversy", "")).lower() == "required" - ) - update_fields.append("filing_requires_amount_in_controversy") - if not obj.document_type_code: - obj.document_type_confirmed = False - update_fields.append("document_type_confirmed") + update_fields += apply_filing_type_flags(obj, option) obj.save(update_fields=update_fields) del obj._metadata_kind record( diff --git a/efile_app/efile/services/handoff_replacements.py b/efile_app/efile/services/handoff_replacements.py index 584733d6..69a001bf 100644 --- a/efile_app/efile/services/handoff_replacements.py +++ b/efile_app/efile/services/handoff_replacements.py @@ -3,18 +3,18 @@ from django.db import transaction from efile.models import FilingDocument, FilingDraft -from efile.services.filing_code_search import validate_path -from efile.services.handoff import HandoffError, record +from efile.services.filing_code_search import FACETS, validate_path +from efile.services.handoff import HandoffError, apply_filing_type_flags, record from efile.workflow import ExistingCase -CASE_FIELDS = ("court", "case_category", "case_type") - def locked_choices(draft, document): - return { - **{facet: getattr(draft, f"{facet}_code") for facet in CASE_FIELDS}, - "filing_type": document.filing_type_code, - } + return {facet: getattr(document if facet == "filing_type" else draft, f"{facet}_code") for facet in FACETS} + + +def lookup_offered(draft, document): + """New cases only, and only while some court choice is still missing.""" + return draft.existing_case == ExistingCase.NEW and not all(locked_choices(draft, document).values()) def compatible(draft, document, path): @@ -44,8 +44,8 @@ def apply_replacement(draft, document, path): or not compatible(current, target, validated) ): raise HandoffError("Your saved choices changed. Search again; nothing was replaced.", status=409) - changed = False - for facet in (*CASE_FIELDS, "filing_type"): + corrections = list(current.correction_fields) + for facet in FACETS: obj = target if facet == "filing_type" else current field = f"{facet}_code" if getattr(obj, field): @@ -54,13 +54,7 @@ def apply_replacement(draft, document, path): setattr(obj, f"{facet}_name", validated[facet]["name"]) update_fields = [field, f"{facet}_name", "updated_at"] if obj is target: - target.filing_requires_amount_in_controversy = ( - str(validated[facet].get("amountincontroversy", "")).lower() == "required" - ) - update_fields.append("filing_requires_amount_in_controversy") - if not target.document_type_code: - target.document_type_confirmed = False - update_fields.append("document_type_confirmed") + update_fields += apply_filing_type_flags(target, validated[facet]) obj._metadata_kind = "code_search_replacement" try: obj.save(update_fields=update_fields) @@ -68,8 +62,7 @@ def apply_replacement(draft, document, path): del obj._metadata_kind event_path = f"documents.{target.pk}.{field}" if obj is target else field record(current, event_path, "user_confirmation", {**validated[facet], "source": "code search"}) - if event_path in current.correction_fields: - current.correction_fields.remove(event_path) - FilingDraft.objects.filter(pk=current.pk).update(correction_fields=current.correction_fields) - changed = True - return changed + if event_path in corrections: + corrections.remove(event_path) + if corrections != current.correction_fields: + FilingDraft.objects.filter(pk=current.pk).update(correction_fields=corrections) diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 3d5b3bfd..2b89af08 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -39,6 +39,7 @@ documents_replaced, effective_hints, fingerprint, + interview_filing_hints, issues_for, populate, receipt_for, @@ -46,8 +47,9 @@ resolve_metadata, validate_payload, ) +from efile.services.handoff_replacements import lookup_offered from efile.utils.s3_upload_handler import S3UploadHandler -from efile.workflow import RETURN_TO_HANDOFF, ExistingCase, WorkflowStepKey, with_return_to +from efile.workflow import RETURN_TO_HANDOFF, WorkflowStepKey, with_return_to CLAIM_SALT = "litefile.handoff.claim.v1" REPLACE_SALT = "litefile.handoff.replace.v1" @@ -231,9 +233,10 @@ def handoff_review(request, draft_id): review_error = "" status = 200 if request.method == "POST": - if request.POST.get("action") in ("resolve", "confirm"): + action = request.POST.get("action") + if action in ("resolve", "confirm"): resolve_metadata(draft) - if request.POST.get("action") == "confirm": + if action == "confirm": # Record the explicit review of existing selections. An unresolved # correction is kept open until its field actually has a value. remaining = [] @@ -251,7 +254,7 @@ def handoff_review(request, draft_id): draft.correction_fields = remaining draft.save(update_fields=["correction_fields", "updated_at"]) record(draft, "review", "user_confirmation", {"user_id": request.user.pk}) - if request.POST.get("action") == "complete_details": + if action == "complete_details": try: approve_document_review(draft, request.POST.get("preview_fingerprint")) except DocumentReviewError as exc: @@ -259,7 +262,7 @@ def handoff_review(request, draft_id): else: remaining = _issue_links(draft) return redirect(remaining[0]["url"] if remaining else reverse("handoff_review", args=[draft.pk])) - elif request.POST.get("action") == "continue": + elif action == "continue": if issues_for(draft): return redirect("handoff_review", draft_id=draft.pk) try: @@ -277,57 +280,42 @@ def handoff_review(request, draft_id): return redirect("handoff_review", draft_id=draft.pk) receipt = receipt_for(draft) documents = list(draft.documents.all()) + parties = list(draft.parties.all()) payload = receipt.payload if receipt else {} hints = effective_hints(payload, court_name=draft.court_name) + case = payload.get("case", {}) imported_choices = [ { - "label": "Court", - "hint": payload.get("case", {}).get("court_name") or payload.get("case", {}).get("county", ""), - "value": (draft.court_name or draft.court_code) if draft.court_code else "", - }, - { - "label": "Case category", - "hint": ", ".join(hints.get("case_category_name_hints", [])), - "value": (draft.case_category_name or draft.case_category_code) if draft.case_category_code else "", - }, - { - "label": "Case type", - "hint": ", ".join(hints.get("case_type_name_hints", [])), - "value": (draft.case_type_name or draft.case_type_code) if draft.case_type_code else "", - }, + "label": label, + "hint": hint, + "value": (getattr(draft, f"{facet}_name") or code) if (code := getattr(draft, f"{facet}_code")) else "", + } + for facet, label, hint in ( + ("court", "Court", case.get("court_name") or case.get("county", "")), + ("case_category", "Case category", ", ".join(hints.get("case_category_name_hints", []))), + ("case_type", "Case type", ", ".join(hints.get("case_type_name_hints", []))), + ) ] + filing_hints = interview_filing_hints(draft, payload, documents) for document in documents: - suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() - source_document = suggestion.value if suggestion else {} - document_hints = effective_hints(payload, court_name=draft.court_name, document=source_document) - document.interview_filing_hint = ", ".join( - document_hints.get("filing_type_name_hints", []) - or (hints.get("filing_type_name_hints", []) if document.role == "lead" else []) - ) - issues = _issue_links(draft) - for choice, field in zip(imported_choices, ("court_code", "case_category_code", "case_type_code"), strict=True): - if choice["hint"] and not choice["value"] and field not in draft.correction_fields: - for issue in issues: - if issue["path"] == field: - issue["message"] = ( - f"{choice['label']}: the interview suggested “{choice['hint']}”, " - "but no court choice is matched. Choose an available option." - ) + document.interview_filing_hint = ", ".join(filing_hints[document.pk]) + # Interview suggestions keyed by issue path. Outside corrections, an issue + # only exists for an empty choice, so a hint there was not matched. unmatched_hints = { - f"documents.{document.pk}.filing_type_code": ( - f"Filing type for {document.name}", - document.interview_filing_hint, - ) - for document in documents - if not document.filing_type_code + **{ + f"{facet}_code": (choice["label"], choice["hint"]) + for facet, choice in zip(("court", "case_category", "case_type"), imported_choices, strict=True) + }, + **{ + f"documents.{document.pk}.filing_type_code": ( + f"Filing type for {document.name}", + document.interview_filing_hint, + ) + for document in documents + }, + **{f"parties.{party.pk}.party_type": (f"Court role for {party}", party.party_role_hint) for party in parties}, } - unmatched_hints.update( - { - f"parties.{party.pk}.party_type": (f"Court role for {party}", party.party_role_hint) - for party in draft.parties.all() - if not party.party_type - } - ) + issues = _issue_links(draft) for issue in issues: label, hint = unmatched_hints.get(issue["path"], ("", "")) if hint and issue["path"] not in draft.correction_fields: @@ -342,22 +330,15 @@ def handoff_review(request, draft_id): "draft": draft, "issues": issues, "imported_choices": imported_choices, - "replacement_documents": [ - document - for document in documents - if draft.existing_case == ExistingCase.NEW - and not all( - (draft.court_code, draft.case_category_code, draft.case_type_code, document.filing_type_code) - ) - ], + "replacement_documents": [document for document in documents if lookup_offered(draft, document)], "receipt": receipt, "documents": documents, "preview_fingerprint": preview_fingerprint(documents), "can_review_documents": bool(documents) and all(doc.preparation for doc in documents), "needs_document_review": any(doc.preparation_reviewed_at is None for doc in documents), "review_error": review_error, - "parties": draft.parties.all(), - "can_return": bool(receipt and receipt.payload.get("return_url")), + "parties": parties, + "can_return": bool(payload.get("return_url")), "case_url": draft_url( with_return_to( reverse("extraction_review", kwargs={"jurisdiction": draft.jurisdiction}), RETURN_TO_HANDOFF diff --git a/efile_app/efile/views/handoff_replacements.py b/efile_app/efile/views/handoff_replacements.py index d7c0748e..c52f7b6d 100644 --- a/efile_app/efile/views/handoff_replacements.py +++ b/efile_app/efile/views/handoff_replacements.py @@ -6,9 +6,16 @@ from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_http_methods +from efile.models import FilingDraft from efile.services.filing_code_search import current_index, search_paths -from efile.services.handoff import HandoffError, effective_hints, receipt_for, resolve_metadata -from efile.services.handoff_replacements import apply_replacement, compatible, locked_choices +from efile.services.handoff import ( + HandoffError, + effective_hints, + interview_filing_hints, + receipt_for, + resolve_metadata, +) +from efile.services.handoff_replacements import apply_replacement, compatible, locked_choices, lookup_offered from efile.views.handoff import _owned, _private from efile.workflow import ExistingCase @@ -27,38 +34,12 @@ def handoff_replacements(request, draft_id, document_id): except HandoffError as exc: return _private(render(request, "efile/handoff_replacements.html", {"error": str(exc)}, status=exc.status)) document = get_object_or_404(draft.documents, pk=document_id) - if draft.status != "draft" or draft.existing_case != ExistingCase.NEW: - return redirect("handoff_review", draft_id=draft.pk) receipt = receipt_for(draft) - if not receipt: - return redirect("handoff_review", draft_id=draft.pk) - if all(locked_choices(draft, document).values()): + if draft.status != FilingDraft.Status.DRAFT or not receipt or not lookup_offered(draft, document): return redirect("handoff_review", draft_id=draft.pk) - suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() - hints = effective_hints( - receipt.payload, court_name=draft.court_name, document=suggestion.value if suggestion else {} - ) - top_hints = effective_hints(receipt.payload, court_name=draft.court_name) - candidates = hints.get("filing_type_name_hints", []) or ( - top_hints.get("filing_type_name_hints", []) if document.role == "lead" else [] - ) - if document.filing_type_code: - candidates = ( - top_hints.get("case_type_name_hints", []) - if not draft.case_type_code - else top_hints.get("case_category_name_hints", []) - ) - default_query = next((hint for hint in candidates if hint.strip()), document.name)[:160] - query = request.GET.get("q", default_query).strip()[:160] - try: - offset = max(0, min(int(request.GET.get("offset", "0")), 10000)) - except ValueError: - offset = 0 index = current_index(draft.jurisdiction) error = "" status = 200 - results = [] - total = 0 if request.method == "POST": try: submitted_id = request.POST.get("path_id", "") @@ -86,13 +67,13 @@ def handoff_replacements(request, draft_id, document_id): error, status = str(exc), getattr(exc, "status", 409) except requests.RequestException: error, status = "The court list could not be checked. Your draft is saved; try again later.", 503 - if request.method == "POST": + # The draft may have changed or been deleted meanwhile; show what is saved now. try: draft = _owned(request, draft_id) except HandoffError as exc: return _private(render(request, "efile/handoff_replacements.html", {"error": str(exc)}, status=exc.status)) document = draft.documents.filter(pk=document_id).first() - if document is None or draft.status != "draft" or draft.existing_case != ExistingCase.NEW: + if document is None or draft.status != FilingDraft.Status.DRAFT or draft.existing_case != ExistingCase.NEW: return _private( render( request, @@ -101,6 +82,19 @@ def handoff_replacements(request, draft_id, document_id): status=status, ) ) + if not document.filing_type_code: + candidates = interview_filing_hints(draft, receipt.payload, [document])[document.pk] + else: + case_hints = effective_hints(receipt.payload, court_name=draft.court_name) + candidates = case_hints.get("case_category_name_hints" if draft.case_type_code else "case_type_name_hints", []) + default_query = next((hint for hint in candidates if hint.strip()), document.name) + query = request.GET.get("q", default_query).strip()[:160] + try: + offset = max(0, min(int(request.GET.get("offset", "0")), 10000)) + except ValueError: + offset = 0 + results = [] + total = 0 if index and query: result = search_paths( index, @@ -114,20 +108,18 @@ def handoff_replacements(request, draft_id, document_id): ) results = result["results"] total = result["total"] + locked = locked_choices(draft, document) saved = [ { "label": label, - "value": getattr(draft if facet != "filing_type" else document, f"{facet}_name") - or locked_choices(draft, document)[facet], + "value": getattr(draft if facet != "filing_type" else document, f"{facet}_name") or locked[facet], } for facet, label in CHOICE_LABELS - if locked_choices(draft, document)[facet] + if locked[facet] ] for result in results: result["changes"] = [ - {"label": label, "value": result[facet]["name"]} - for facet, label in CHOICE_LABELS - if not locked_choices(draft, document)[facet] + {"label": label, "value": result[facet]["name"]} for facet, label in CHOICE_LABELS if not locked[facet] ] return _private( render( @@ -141,7 +133,6 @@ def handoff_replacements(request, draft_id, document_id): "saved": saved, "error": error, "lookup_available": index is not None, - "total": total, "next_results": "?" + urlencode({"q": query, "offset": offset + 20}) if offset + 20 < total and offset < 10000 else "",