From e69a31054d6f01446163dd5f8fd88e6c7e3e39ef Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 8 Sep 2026 19:25:49 -0400 Subject: [PATCH 1/3] Implement Docassemble filing handoffs --- .../issue-106-vermont-handoff-validation.md | 196 ++++++ .../partners-courts/interview-integration.md | 264 +++++++- docs/docs/user-guide/case-management.md | 5 +- .../commands/expire_unclaimed_handoffs.py | 48 ++ efile_app/efile/middleware.py | 16 +- .../migrations/0023_interview_handoff.py | 152 +++++ efile_app/efile/models.py | 69 +- efile_app/efile/services/draft_urls.py | 5 +- efile_app/efile/services/drafts.py | 18 +- efile_app/efile/services/filings.py | 11 +- efile_app/efile/services/handoff.py | 602 ++++++++++++++++++ efile_app/efile/settings_base.py | 7 + efile_app/efile/signals.py | 54 ++ .../efile/static/config/states/vermont.yaml | 15 + .../efile/templates/efile/confirmation.html | 1 + .../efile/templates/efile/correct_filing.html | 15 + .../efile/templates/efile/filing_detail.html | 3 + .../efile/templates/efile/handoff_claim.html | 15 + .../efile/templates/efile/handoff_review.html | 30 + .../efile/templates/efile/workflow_base.html | 3 + efile_app/efile/tests/test_handoff.py | 419 ++++++++++++ efile_app/efile/urls.py | 14 + efile_app/efile/views/confirmation.py | 10 +- efile_app/efile/views/handoff.py | 415 ++++++++++++ efile_app/efile/views/login.py | 3 + efile_app/efile/views/my_cases.py | 2 + efile_app/efile/views/my_drafts.py | 3 + 27 files changed, 2371 insertions(+), 24 deletions(-) create mode 100644 docs/developer-notes/issue-106-vermont-handoff-validation.md create mode 100644 efile_app/efile/management/commands/expire_unclaimed_handoffs.py create mode 100644 efile_app/efile/migrations/0023_interview_handoff.py create mode 100644 efile_app/efile/services/handoff.py create mode 100644 efile_app/efile/templates/efile/correct_filing.html create mode 100644 efile_app/efile/templates/efile/handoff_claim.html create mode 100644 efile_app/efile/templates/efile/handoff_review.html create mode 100644 efile_app/efile/tests/test_handoff.py create mode 100644 efile_app/efile/views/handoff.py diff --git a/docs/developer-notes/issue-106-vermont-handoff-validation.md b/docs/developer-notes/issue-106-vermont-handoff-validation.md new file mode 100644 index 00000000..60c7a149 --- /dev/null +++ b/docs/developer-notes/issue-106-vermont-handoff-validation.md @@ -0,0 +1,196 @@ +# Vermont interview handoff validation + +Validated on September 8, 2026, using synthetic answers in the running local +Docassemble server, the installed `docassemble.RFApackage`, an isolated LITEFile +SQLite database, private LocalStack S3 storage, and the dev EFSP's live Vermont +court metadata. No filing was sent during the initial pass. The later dev +submission is documented below. + +## Implementation exercised + +The adapter was installed with: + +```bash +~/venv/bin/dainstall --server localhost ~/docassemble-RFApackage +``` + +The interview was completed with synthetic adult RFA answers through the +Docassemble session API and browser. It generated four actual court PDFs: +complaint, affidavit, confidential contact information, and service information. +The next-steps instructions were excluded from the transfer. + +Playwright exercised the browser handoff, ownership claim, missing-details +screen, document checklist, document classification form, and return to the +interview. The Docassemble API supplied and changed substantive answers; PDF +creation and transfer ran in the installed interview. + +## Observed results + +- The source-authenticated multipart handoff created one durable draft with four + privately stored PDFs. Repeating the transfer from the interview succeeded + without another receipt, draft, or document set. +- Claiming the draft resolved Chittenden Unit, the Family category, the adult + Relief from Abuse case type, plaintiff/defendant party types, and three + supporting filing types against live Vermont metadata. Filer and party names + and contact information were prefilled. +- The source's `Complaint` hint had no unique live filing-type match. LITEFile + left it editable. Browser selection of a dev filing type, confidentiality, + and components saved successfully and returned directly to the handoff + screen. Only payment/fee work remained in the missing-details list. +- A simulated submitted outcome and confirmed clerk return created a correction + revision through the actual correction view, with only the court detail + fetch/response mocked. The previous submission snapshot, parties, document + references, and filing choices were preserved. +- The browser returned to the same Docassemble session using the scoped + correction link. An updated synthetic incident description generated new + PDFs and transferred them to the same correction draft. All four storage + references changed; filing types, confidentiality, and components remained + unchanged. Text extraction from the stored replacement affidavit confirmed + the corrected incident description, rather than merely a changed PDF hash. +- A second simulated clerk return targeted the lead filing type. The correction + draft retained the PDFs and supporting classifications. Repairing the lead + classification in the browser cleared the correction requirement without + rerunning the interview. +- Automated tests cover partial handoffs, authentication and ownership, + idempotency, invalid schemas and hashes, provenance, ambiguous/partly accepted + outcomes, immutable snapshots, correction/replacement retries, same-matter + grouping, expiring claims, unclaimed retention, targeted navigation, and + renewed private document URLs. Sender tests include adult/minor case hints. + +The temporary test configuration and scripts contain credentials or private +session links and are deliberately outside the repositories. The dev server +uses an isolated database; synthetic submission outcomes were not added to the +ordinary development database. + +## Bugs fixed during validation + +- The claim screen's `no-referrer` policy caused a browser form submission with + `Origin: null`, which Django rejected. Same-origin referrers permit CSRF checks + while suppressing referrers on the external interview return. +- Regenerating PDFs for a retry changed PDF timestamps and hashes. The interview + now retains AssemblyLine's cache, payload, and `DAFile` handles per transfer, + without making independent frozen copies. +- The first cache implementation caught Docassemble's `DAIndexError`, which is + how Docassemble asks the attachment block to assemble an undefined final PDF. + Letting that exception propagate allows all four attachments to resolve. +- An event-only `BackgroundAction` wait returned to the host interview's + download screen before consuming its result. A generic initial routing block + now resumes pending uploads before the host interview's mandatory blocks run. +- A document-content correction incorrectly also displayed “Add the main PDF” + for an existing lead document. Missing-lead and replacement requirements now + have separate paths. +- Durable imported PDFs now receive fresh private URLs when resumed instead of + relying on the signature saved during the original handoff. +- Malformed return URLs produce a validation error instead of an unhandled + parser exception. + +## Verification limits + +The configured Vermont sign-in request returned upstream HTTP 500 with +`jakarta.xml.ws.WebServiceException: Could not send Message`. Browser ownership +and editing therefore used a synthetic local authenticated session. Court +metadata requests used the live dev service; court submission and clerk status +were not live-tested. Clerk outcomes were explicitly simulated through the +correction view. The environment's `EFSP_TEST_DOCUMENT_URL` override was active, +so a dev fee quote or submission would also use the configured stand-in PDF. + +A real authenticated fee/review/submission/status check remained outstanding +at the end of the initial pass; see the credential retry below. The adult interview was exercised on the server; the +minor-specific semantic hint was verified by a sender unit test. + +## Final automated checks + +- LITEFile: `uv run pytest -q` — 782 passed. +- Vermont sender: `python -m pytest -q tests/test_litefile.py` — 16 passed. +- LITEFile: `uv run ruff check .` and `uv run ty check` — passed. +- Django: `makemigrations --check --dry-run` — no model drift; migration 0023 + was applied to the isolated test database. +- Public documentation: `npm run build` — passed. +- Both repositories: `git diff --check` — passed. + +## Updated Vermont credential retry + +After the Vermont-specific password was updated, the requested Luna subagent +retried authentication and received HTTP 200. LITEFile authenticated the real +Vermont account. A fresh dev handoff containing one synthetic PDF was claimed +by that account and reached the handoff review hub, resolving Chittenden Unit +and Relief from Abuse against live metadata. The local database confirms that +this draft has a different owner from the earlier synthetic login, remains +editable, and has no submission response. + +The sign-in blocker is resolved. This retry verifies real authentication and +ownership claim; it does not establish fee calculation, submission, or court +acceptance. No court submission was made in this retry. + +## Authorized dev submission + +After explicit authorization to submit to the dev server, the installed +Docassemble interview prepared a fresh source identity and transferred all four +court PDFs into a new draft. Each private storage object matched the source's +SHA-256 hash. The real Vermont account claimed the draft and completed live +metadata selection, payment, and final review. + +- Confirmed proxy host: `efile-test.suffolklitlab.org`. +- Quoted fee: $14.40 using the saved test VISA payment method. +- One submission succeeded; the draft persisted as `submitted`, with an + immutable submission snapshot, case ID, envelope ID, and four filing IDs. +- The dev filing-status list returned HTTP 200. Individual detail queries + initially returned `submitted` for two filings; the other queries were + temporarily unavailable or timed out. Read-only retries confirmed the other + two as `submitted`, with matching filing IDs. All four filings are now + confirmed submitted. The submission itself was never retried. +- Browser verification after the fix showed “Your filing was sent to the court,” + a visible confirmation reference, and four documents sent. +- The dev stand-in PDF setting remained active. Actual interview PDFs were + generated, transferred, and verified in LITEFile, while the remote proxy + fetched the configured stand-in PDF for each submitted document. This test + demonstrates the dev filing transaction, not clerk acceptance or review of + the actual RFA document contents. + +The real response exposed two bugs that were fixed: `filingIds` lists now link +submitted filings to their draft, matter history, and correction flow; and the +confirmation screen uses `envelopeId` as its confirmation reference instead of +missing it or displaying a nested contact ID. Regression checks passed: +55 handoff, review/submission, and filing-integrity tests, plus Ruff and type +checks. The final full suite passed 782 tests, including these regressions. + +## Declarative adapter cleanup + +The final adapter moves all interview customization into `litefile.yml`: +source variable paths, adult/minor case hints, party +roles, per-document filing/document/component hints, configuration lookup, and +navigation. The main RFA interview only includes that YAML and displays its +`litefile_continue_button` template. The reusable Python module contains no RFA +or Vermont branches, variable names, document mappings, or classifications. + +PDF handling now retains AssemblyLine's `get_cacheable_documents()` result per +transfer. It no longer creates separate frozen DAFile copies. The payload and +cached file handles are reused on retry; a new correction token obtains a new +AssemblyLine cache. + +Installed the refactored adapter on the running Docassemble server and verified: + +- Four actual PDFs transferred through `BackgroundAction` into one new draft, + and the success screen appeared after one click. +- Retrying returned HTTP 200 and preserved one receipt, draft 10, the same + source identity, and four documents. +- A substantive answer edit and correction transfer replaced all four PDFs in + that same draft while preserving its filing metadata. +- Retrying the correction kept one replacement receipt and two total transfer + caches (initial and correction). +- Text extraction from the stored affidavit confirmed the new correction text. +- Sixteen adapter tests, Ruff, whitespace checks, and the documentation build + pass. + +The reusable person helper defaults to the standard AssemblyLine and +Docassemble name, address, email, and phone attributes. An interview can pass a +keyword-only `fields` mapping for different object shapes and tests can replace +`showifdef` through the keyword-only `known` argument. + +The final live retry used the flat `litefile` server configuration and logged an +empty argument mapping for `litefile_upload`. Credentials, correction tokens, +and interview answers therefore remained outside the background job arguments. + +The first attempt encountered a stopped LocalStack container and a recoverable +HTTP 503. Restarting local storage allowed the same cached transfer to succeed. +No additional court submission was made during this refactor validation. diff --git a/docs/docs/partners-courts/interview-integration.md b/docs/docs/partners-courts/interview-integration.md index 711d6a29..d9132043 100644 --- a/docs/docs/partners-courts/interview-integration.md +++ b/docs/docs/partners-courts/interview-integration.md @@ -5,29 +5,265 @@ sidebar_label: Guided interview integration sidebar_position: 5 --- -# Guided interview & Docassemble integration WIP +# Guided interview and Docassemble integration -LITEFile pairs naturally with guided interview engines like the [Document Assembly Line](https://assemblyline.suffolklitlab.org) and [Docassemble](https://docassemble.org) to provide an end-to-end access to justice pipeline: +A guided interview can send its generated PDFs and already-collected answers to +LITEFile. LITEFile saves a draft, resolves filing hints against current court +choices, and asks the filer for missing details. A successful transfer means +**draft created**, not **filed with the court**. +Docassemble owns the legal interview and documents. LITEFile owns court codes, +filing classifications, party types, fees, payment, submission, and clerk returns. +The Vermont `docassemble.RFApackage` interview includes a working sender. + +## Configure a source + +Apply the database migrations before enabling a source (`uv run python manage.py migrate` +from `efile_app/`). Migration `0023_interview_handoff` adds receipts, provenance, +and correction revisions. + +Set `LITEFILE_HANDOFF_SOURCES` in the LITEFile environment to a JSON object: + +```json +{ + "vermont-rfa": { + "token": "a-long-random-secret", + "jurisdictions": ["vermont"], + "return_origins": ["https://interviews.example.org"] + } +} ``` -Pro Se User → Guided Interview Form Prep → PDF Generated → LITEFile Pre-fill & Upload → EFSP E-Filing → Court Review + +Give each source its own secret and jurisdiction allowlist. Store the secret in +server configuration, never interview text, browser JavaScript, or a URL. HTTPS +is required for return links. Source authentication is independent of the +filer's e-filing account; do not send the filer's password through this API. + +Run database migrations when deploying the receiver. Configure the existing S3 +upload handler with a private bucket. LITEFile accepts uploaded PDFs; it does +not fetch arbitrary document URLs supplied by an interview. + +## Version 1 request + +Send `POST /api/handoffs/v1/` with these headers: + +```text +Authorization: Bearer +X-LITEFile-Source: vermont-rfa ``` ---- +For documents, use `multipart/form-data`: one text field named `payload` containing +JSON and one PDF part per document, named with that document's `id`. Do not put +Docassemble expressions or unresolved variable references in the payload. The +sender evaluates only answers that are already defined, and omits unknown values. +A request without documents may instead use `application/json`. -## 1. Handoff architecture +```json +{ + "schema_version": 1, + "jurisdiction": "vermont", + "source_id": "opaque-stable-interview-handoff-id", + "idempotency_key": "opaque-stable-request-id", + "filing_intent": "relief_from_abuse", + "case_category_name_hints": ["Family"], + "case_type_name_hints": ["Relief from Abuse"], + "filing_type_name_hints": ["Complaint"], + "case": { + "existing_case": false, + "court_name": "Chittenden Family Division", + "county": "Chittenden" + }, + "filer": { + "first_name": "Example", + "last_name": "Filer", + "email": "example@example.org" + }, + "parties": [ + { + "first_name": "Example", + "last_name": "Filer", + "semantic_role": "plaintiff", + "case_side_hint": "plaintiff", + "is_self": true, + "is_filing_party": true + } + ], + "documents": [ + { + "id": "RFAcomplaint", + "role": "lead", + "form_name": "RFA Complaint", + "sha256": "<64 lowercase hexadecimal characters from the uploaded PDF>" + } + ], + "known_filing_facts": {}, + "return_url": "https://interviews.example.org/interview?session=" +} +``` -When a user completes a guided interview on a Docassemble or AssemblyLine server, the interview can package the resulting PDF files and redirect the user directly to LITEFile with pre-populated session variables. +Only `schema_version`, `jurisdiction`, `source_id`, and `idempotency_key` are +required for an empty draft. Case, filer, parties, hints, facts, and documents +may be partial or omitted. `existing_case` is a JSON boolean when known. +`case` also accepts `docket_number` and `case_title`. Existing court cases still +need a live lookup and confirmation of the case identifier. -### Key integration points: -1. **Direct PDF delivery via S3**: The interview uploads generated PDFs directly to the secure S3 bucket with temporary pre-signed keys. -2. **Session pre-fill API**: The interview transmits initial metadata (jurisdiction, court code, case category, party names) to LITEFile's session API. -3. **Seamless authentication**: If single sign-on is enabled between platforms, the user transitions straight into the review and submission steps. +People accept `first_name`, `middle_name`, `last_name`, `suffix`, +`organization_name`, `email`, `phone`, `address_line_1`, `address_line_2`, `city`, +`state`, `zip_code`, and a two-letter `country`. A party marked `is_self: true` +merges into the filer row, preserving collected contact details. +`is_filing_party` means the filing is made on that person's behalf. It does not +mean the person operating the browser is necessarily a party. ---- +Documents use stable source IDs, `lead` or `supporting` roles, and SHA-256 hashes. +A nonempty bundle must contain exactly one lead. The limits are 20 PDFs, 10 MB +per PDF, 100 parties, and 512 KB of JSON metadata. Unknown source facts and numeric +suggestions remain in the receipt; numeric suggestions never populate resolved +court-code fields. Supported questionnaire answers `has_children` and +`child_count` also populate their normal filing fields. + +## Response, continuation, and retries + +A new receipt returns HTTP 201. An identical retry returns HTTP 200 with the same +draft ID and a fresh continuation link: + +```json +{ + "draft_id": "18427", + "state": "needs_input", + "issues": [ + { + "code": "court_code_required", + "path": "court_code", + "message": "Choose the court.", + "view": "extraction_review" + } + ], + "continue_url": "https://litefile.example.org/handoff/claim//" +} +``` + +The browser follows `continue_url`, signs in to its jurisdiction's account, and +explicitly claims the draft. Merely opening a link does not assign ownership. +Claim links expire after 24 hours by default; retry the same request to obtain a +fresh link. Once claimed, only the owning account can open or edit the draft. +Treat the continuation link as a private capability and avoid logging it. + +A `(source, source_id)` identifies one original draft; a `(source, +idempotency_key)` identifies one request. Reusing either identity with different +content returns HTTP 409. Retry uncertain network outcomes with the **same** +payload, key, document IDs, and hashes. Do not make a new source ID on timeout. +Invalid schemas or PDF hashes return HTTP 400, bad authentication HTTP 401, +unauthorized jurisdictions HTTP 403, and unavailable document storage HTTP 503. + +## Resolve and repair filing details + +Claiming the draft looks up semantic hints against live court lists. The saved +answers screen also has a button to repeat that lookup after a court selection. +Only a unique name match preselects a value. Ambiguous or unmatched hints stay +editable. A code the filer has already chosen is not replaced by another guess. -## 2. Roadmap & standards +State YAML can define `handoff.filing_intents`, with +`case_category_name_aliases`, `case_type_name_aliases`, `filing_type_name_aliases`, +and `documents..filing_type_name_aliases`. These curated +names supplement source hints. The Vermont configuration contains aliases for +supporting RFA forms. Court confidentiality choices remain part of filing review. -The LIT Lab is actively developing standardized data schemas for guided interview to e-filing handoffs based on the **Electronic Court Filing (ECF) 5.0** standard and the **Legal XML** working group specifications. +The receipt preserves original suggestions. Append-only metadata events record +live resolutions, user edits and confirmations, clerk corrections, and replacement +PDF hashes. Targeted edit links return to the missing-details list, so complete +answers do not have to be entered again. Final payment, payload validation, and +submission remain the ordinary LITEFile workflow; an empty `issues` list is not +an EFSP acceptance guarantee. + +## Clerk returns and document corrections + +From a locally submitted filing, choose **Correct and resubmit**. LITEFile checks +the court's current status for every filing identifier in that submission. +Only confirmed `rejected` or `returned` statuses permit a correction revision. +Pending, uncertain, partly accepted, and transport-failure outcomes cannot be +cloned automatically. + +Select the fields the clerk asked to correct. The correction draft keeps the +same matter, parties, and unchanged documents. The previous submission snapshot +remains unchanged. Corrected classifications and their dependent choices are +cleared; fees must be checked again. Repeating the correction action resumes the +same revision. Filing history groups the attempts together, including returns +that occurred before a court assigned a case number. + +For substantive PDF changes, use **Return to my interview to correct a PDF**. +LITEFile adds an expiring `litefile_correction` parameter to the approved return +URL. Docassemble resumes the existing interview. After the filer edits the +answers, the sender posts the updated PDFs to `/api/handoffs/v1/documents/`, +with its normal source headers and `X-LITEFile-Correction: `. + +The replacement request uses the original `source_id`, stable document IDs, +updated hashes, and a replacement idempotency key. It changes PDF storage +references, retains the draft's filing metadata, records the replacement, and +invalidates its fee quote. It cannot modify submitted or uncertain attempts. +Other source answers in a replacement request do not overwrite the filer's +LITEFile edits. Documents removed in LITEFile must be added there again. + +## Configure the Vermont sender + +In the Docassemble server configuration: + +```yaml +litefile: + enabled: true + base_url: https://litefile.example.org + source: court-interviews + token: a-long-random-secret +``` + +This top-level configuration is shared by the Docassemble server's interviews. +Staging and production use their own credentials and endpoint. An interview can +override `litefile_config_name` in a later YAML block to select another top-level +configuration; its default is `"litefile"`. Migrate an older `litefile.rfa` +configuration by moving its contents up to `litefile`. + +The RFA download screen then offers **Continue in LITEFile**. The adapter sends +the enabled court bundle, excluding the separate next-steps instructions. Its +only responsibilities are collecting known facts, hashing and transferring PDFs, +source authentication, idempotency, and presenting the continuation link. + +Each adaptation includes its own `litefile.yml`, which imports the reusable +transport module. Its `data` and `data from code` blocks declare source variable +paths, party roles, semantic hints, and document mappings. The reusable helper +defaults to the normal AssemblyLine and Docassemble person attributes; an +interview can pass a custom `fields={...}` mapping when its objects differ. +The main interview only displays the included `litefile_continue_button` +template; configuration and navigation live in the adapter YAML. + +Generic `litefile_*` variables and events let authors override defaults with +later data or code blocks. The shared `litefile_send` event prepares and saves +the AssemblyLine cache, then uses Docassemble's `BackgroundAction` for the +network upload. Its first wait response persists the cache before the worker +reads it. An initial routing block consumes the result before the host +interview's mandatory blocks run. The worker reads credentials from the +selected server configuration, while the background task arguments remain +empty. + +The Python helper has no interview-specific paths or classifications. It uses +AssemblyLine's `get_cacheable_documents()` and retains that cache with the +transfer payload, so retries reuse file handles and hashes without making +separate frozen PDF copies. A new correction token starts a new cache. + + +For isolated local testing, both source configurations can explicitly set +`allow_insecure_local_development: true`. The receiver also requires Django +`DEBUG=True`; production return links still require HTTPS. Use synthetic answers +and the dev EFSP, and keep local testing credentials outside version control. + +## Retain and expire unclaimed data + +A draft exists before the filer claims it, so unclaimed data needs a retention +schedule. Preview expired handoffs with: + +```bash +uv run python manage.py expire_unclaimed_handoffs --days 7 +``` -For technical inquiries or to participate in integration testing, contact the [Suffolk LIT Lab](mailto:litlab@suffolk.edu). +Add `--apply` to delete those unclaimed receipts, drafts, and private PDFs. +Claimed drafts and submission revisions are unaffected. Schedule this command +according to the deployment's retention policy; also configure access-log +redaction for capability URLs and a storage lifecycle for failed-upload orphans. diff --git a/docs/docs/user-guide/case-management.md b/docs/docs/user-guide/case-management.md index 49082da7..ee63a28c 100644 --- a/docs/docs/user-guide/case-management.md +++ b/docs/docs/user-guide/case-management.md @@ -53,4 +53,7 @@ If a court clerk rejects your filing, do not panic! Filings are often rejected f 1. Open the rejected filing in **My Cases**. 2. Read the exact **Clerk Rejection Note** explaining why the document was returned. 3. If you need clarification on local requirements, check the court clerk contact information provided directly on the filing details screen. -4. Correct your PDF document, start a new filing, and re-upload the corrected form. +4. Choose **Correct and resubmit** when available. Select the fields the clerk asked you to correct. LITEFile keeps your other answers and documents in the same matter. +5. If the PDF needs changes, use **Return to my interview to correct a PDF**, or replace the document in LITEFile. Review the updated details and fees before submitting again. + +LITEFile checks the court's status before creating a correction draft. If the outcome is uncertain or part of the submission was accepted, contact the clerk before trying to file again. Filings made outside LITEFile may not have a saved draft to correct. diff --git a/efile_app/efile/management/commands/expire_unclaimed_handoffs.py b/efile_app/efile/management/commands/expire_unclaimed_handoffs.py new file mode 100644 index 00000000..40b679e0 --- /dev/null +++ b/efile_app/efile/management/commands/expire_unclaimed_handoffs.py @@ -0,0 +1,48 @@ +"""Remove expired unclaimed interview data and its privately stored PDFs.""" + +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from efile.models import FilingDocument, FilingDraft, InterviewHandoff +from efile.utils.s3_upload_handler import S3UploadHandler + + +class Command(BaseCommand): + help = "Preview unclaimed handoffs older than seven days; use --apply to delete them." + + def add_arguments(self, parser): + parser.add_argument("--days", type=int, default=7) + parser.add_argument("--apply", action="store_true") + + def handle(self, *args, **options): + days = options["days"] + if days < 1: + raise CommandError("--days must be positive.") + cutoff = timezone.now() - timedelta(days=days) + receipts = InterviewHandoff.objects.filter(created_at__lt=cutoff, draft__user__isnull=True) + ids = list(receipts.values_list("draft_id", flat=True)) + if not options["apply"]: + self.stdout.write(f"Would expire {len(ids)} unclaimed handoffs.") + return + handler = S3UploadHandler() + if ids and not handler._ensure_initialized(): + raise CommandError("Document storage is unavailable; no receipts were deleted.") + removed = 0 + for draft_id in ids: + with transaction.atomic(): + draft = FilingDraft.objects.select_for_update().filter(pk=draft_id, user__isnull=True).first() + if draft is None: + continue + for key in draft.documents.exclude(s3_key="").values_list("s3_key", flat=True): + if not FilingDocument.objects.filter(s3_key=key).exclude(draft=draft).exists(): + result = handler.delete_file(key) + if not result.get("success"): + raise CommandError( + "Document storage deletion failed; the remaining receipts were retained." + ) + draft.delete() + removed += 1 + self.stdout.write(f"Expired {removed} unclaimed handoffs.") diff --git a/efile_app/efile/middleware.py b/efile_app/efile/middleware.py index d216b67e..86c59036 100644 --- a/efile_app/efile/middleware.py +++ b/efile_app/efile/middleware.py @@ -1,6 +1,7 @@ from django.contrib.auth import logout from django.http import JsonResponse from django.shortcuts import render +from django.urls import reverse from django.utils.deprecation import MiddlewareMixin from efile.models import FilingDraft @@ -42,14 +43,27 @@ def process_exception(self, request, exception): def process_response(self, request, response): draft = getattr(request, "filing_draft", None) if draft is not None: + # A targeted edit opened from the interview handoff returns to its + # missing-details list, instead of re-asking completed later steps. + targeted = request.GET.get("return_to") == "handoff" or request.POST.get("return_to") == "handoff" + destination = None + if ( + targeted + and request.method == "POST" + and response.status_code < 400 + and draft.status == FilingDraft.Status.DRAFT + ): + destination = reverse("handoff_review", args=[draft.pk]) if response.has_header("Location"): + if destination: + response["Location"] = destination response["Location"] = draft_url(response["Location"], draft.pk) elif isinstance(response, JsonResponse): import json payload = json.loads(response.content) if isinstance(payload, dict) and isinstance(payload.get("redirect_url"), str): - payload["redirect_url"] = draft_url(payload["redirect_url"], draft.pk) + payload["redirect_url"] = destination or draft_url(payload["redirect_url"], draft.pk) response.content = json.dumps(payload) return response diff --git a/efile_app/efile/migrations/0023_interview_handoff.py b/efile_app/efile/migrations/0023_interview_handoff.py new file mode 100644 index 00000000..be2a8a72 --- /dev/null +++ b/efile_app/efile/migrations/0023_interview_handoff.py @@ -0,0 +1,152 @@ +# Generated by Django 5.2.5 on 2026-09-07 22:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("efile", "0022_sync_primary_filing_type"), + ] + + operations = [ + migrations.AddField( + model_name="filingdraft", + name="clerk_return", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="filingdraft", + name="correction_fields", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="filingdraft", + name="correction_of", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="correction", + to="efile.filingdraft", + ), + ), + migrations.AddField( + model_name="filingdraft", + name="submission_snapshot", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name="filingdraft", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="filing_drafts", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="FilingMetadataEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("path", models.CharField(max_length=255)), + ("kind", models.CharField(max_length=40)), + ("value", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="metadata_events", + to="efile.filingdraft", + ), + ), + ], + options={ + "ordering": ["pk"], + }, + ), + migrations.CreateModel( + name="HandoffDocumentUpdate", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("source", models.CharField(max_length=100)), + ("idempotency_key", models.CharField(max_length=255)), + ("fingerprint", models.CharField(max_length=64)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="efile.filingdraft", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("source", "idempotency_key"), + name="handoff_document_update_identity", + ) + ], + }, + ), + migrations.CreateModel( + name="InterviewHandoff", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("source", models.CharField(max_length=100)), + ("source_id", models.CharField(max_length=255)), + ("idempotency_key", models.CharField(max_length=255)), + ("fingerprint", models.CharField(max_length=64)), + ("payload", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "draft", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="handoff", + to="efile.filingdraft", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("source", "source_id"), name="handoff_source_identity" + ), + models.UniqueConstraint( + fields=("source", "idempotency_key"), name="handoff_idempotency" + ), + ], + }, + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index c8ea90a7..d6d4765e 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -176,6 +176,8 @@ class Status(models.TextChoices): user = models.ForeignKey( settings.AUTH_USER_MODEL, + null=True, + blank=True, on_delete=models.CASCADE, related_name="filing_drafts", ) @@ -188,6 +190,13 @@ class Status(models.TextChoices): on_delete=models.SET_NULL, related_name="filing_drafts", ) + correction_of = models.OneToOneField( + "self", null=True, blank=True, on_delete=models.PROTECT, related_name="correction" + ) + submission_snapshot = models.JSONField(default=dict, blank=True) + clerk_return = models.JSONField(default=dict, blank=True) + correction_fields = models.JSONField(default=list, blank=True) + jurisdiction = models.CharField(max_length=40, db_index=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True) current_step = models.CharField( @@ -281,11 +290,23 @@ def __str__(self): def mark_submitted(self, response_data): sync_primary_filing_type(self) + from efile.services.handoff import full_snapshot + + self.submission_snapshot = full_snapshot(self) self.status = self.Status.SUBMITTED self.current_step = WorkflowStepKey.CONFIRMATION self.submission_response = response_data or {} self.submitted_at = timezone.now() - self.save(update_fields=["status", "current_step", "submission_response", "submitted_at", "updated_at"]) + self.save( + update_fields=[ + "status", + "current_step", + "submission_response", + "submission_snapshot", + "submitted_at", + "updated_at", + ] + ) def mark_error(self, response_data): self.status = self.Status.ERROR @@ -486,3 +507,49 @@ class Meta: def __str__(self): display_name = " ".join(part for part in [self.first_name, self.middle_name, self.last_name] if part) return display_name or self.organization_name or f"{self.role} for draft #{self.draft_id}" + + +class InterviewHandoff(models.Model): + """Authenticated source receipt, retained independently of browser sessions.""" + + draft = models.OneToOneField(FilingDraft, on_delete=models.CASCADE, related_name="handoff") + source = models.CharField(max_length=100) + source_id = models.CharField(max_length=255) + idempotency_key = models.CharField(max_length=255) + fingerprint = models.CharField(max_length=64) + payload = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["source", "source_id"], name="handoff_source_identity"), + models.UniqueConstraint(fields=["source", "idempotency_key"], name="handoff_idempotency"), + ] + + +class FilingMetadataEvent(models.Model): + """Append-only provenance of suggestions, resolutions, and corrections.""" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE, related_name="metadata_events") + path = models.CharField(max_length=255) + kind = models.CharField(max_length=40) + value = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["pk"] + + +class HandoffDocumentUpdate(models.Model): + """A replacement receipt makes retries safe without overwriting user edits.""" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE) + source = models.CharField(max_length=100) + idempotency_key = models.CharField(max_length=255) + fingerprint = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["source", "idempotency_key"], name="handoff_document_update_identity") + ] diff --git a/efile_app/efile/services/draft_urls.py b/efile_app/efile/services/draft_urls.py index bf2429ba..51ddfb94 100644 --- a/efile_app/efile/services/draft_urls.py +++ b/efile_app/efile/services/draft_urls.py @@ -47,9 +47,12 @@ def browser_draft_context(request): draft = getattr(request, "filing_draft", None) if draft is None: return {} + from efile.services.handoff import receipt_for + return { + "handoff_draft_id": draft.pk if draft.correction_of_id or receipt_for(draft) else None, "draft_scope": { "id": draft.pk, "paths": [reverse(name, kwargs={"jurisdiction": draft.jurisdiction}) for name in sorted(WORKFLOW_VIEWS)], - } + }, } diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index a31221b7..da2df66c 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -511,10 +511,10 @@ def write_upload_data( return draft -def _document_file(doc: FilingDocument) -> dict[str, Any]: +def _document_file(doc: FilingDocument, urls: dict[int, str] | None = None) -> dict[str, Any]: file_obj: dict[str, Any] = {} _put(file_obj, "name", doc.name) - _put(file_obj, "url", doc.public_url) + _put(file_obj, "url", (urls or {}).get(doc.pk, doc.public_url)) _put(file_obj, "s3_key", doc.s3_key) _put(file_obj, "type", doc.content_type) _put(file_obj, "size", doc.size) @@ -546,10 +546,20 @@ def read_upload_data(draft: FilingDraft | None) -> dict[str, Any]: FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).order_by("sort_order") ) + # Imported PDFs must remain usable after the handoff link's original + # signature expires. Regenerate access URLs from durable private S3 keys. + from efile.services.handoff import receipt_for + from efile.utils.s3_upload_handler import S3UploadHandler + + urls = {} + if receipt_for(draft): + handler = S3UploadHandler() + if handler._ensure_initialized(): + urls = {doc.pk: handler.get_public_url(doc.s3_key) for doc in [lead, *supporting] if doc and doc.s3_key} files: dict[str, Any] = {} if lead is not None: - files["lead"] = _document_file(lead) - supporting_files = [_document_file(doc) for doc in supporting] + files["lead"] = _document_file(lead, urls) + supporting_files = [_document_file(doc, urls) for doc in supporting] if supporting_files: files["supporting"] = supporting_files diff --git a/efile_app/efile/services/filings.py b/efile_app/efile/services/filings.py index 63291826..dd0c49b3 100644 --- a/efile_app/efile/services/filings.py +++ b/efile_app/efile/services/filings.py @@ -240,10 +240,17 @@ def cases_for_user( names = court_names(jurisdiction) archived = archived_case_ids(request.user, jurisdiction) + from efile.services.handoff import matter_keys + + linked_matters = matter_keys(request.user, jurisdiction, filings) cases: dict[str, dict[str, Any]] = {} for filing in filings: tracking_id = str(filing.get("case_tracking_id") or "") - key = tracking_id or f"filing:{filing.get('filing_id') or filing.get('envelope_id') or len(cases)}" + key = ( + linked_matters.get(str(filing.get("filing_id", ""))) + or tracking_id + or f"filing:{filing.get('filing_id') or filing.get('envelope_id') or len(cases)}" + ) described = describe_filing(filing, names) entry = cases.setdefault( key, @@ -260,6 +267,8 @@ def cases_for_user( ) # The court fills in a case title and number once it indexes the case, # so later filings in the same case know more than the first one did. + entry["case_tracking_id"] = entry["case_tracking_id"] or tracking_id + entry["is_archived"] = bool(entry["case_tracking_id"]) and entry["case_tracking_id"] in archived entry["case_title"] = entry["case_title"] or filing.get("case_title", "") entry["docket_number"] = entry["docket_number"] or filing.get("case_number", "") entry["filings"].append(described) diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py new file mode 100644 index 00000000..28d18ffe --- /dev/null +++ b/efile_app/efile/services/handoff.py @@ -0,0 +1,602 @@ +"""Version 1 interview handoff: facts in, durable editable filing out.""" + +from __future__ import annotations + +import hashlib +import json +from urllib.parse import urlsplit + +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.db import transaction +from django.forms.models import model_to_dict +from django.utils import timezone + +from efile.models import FilingDocument, FilingDraft, FilingMetadataEvent, FilingParty, InterviewHandoff +from efile.party_sides import side_for_party_type_name +from efile.services.document_checklists import normalize_name +from efile.services.filing_plans import _codes +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase, WorkflowStepKey + +MAX_DOCUMENT_BYTES = 10 * 1024 * 1024 +PERSON_FIELDS = ( + "first_name", + "middle_name", + "last_name", + "suffix", + "organization_name", + "email", + "phone", + "address_line_1", + "address_line_2", + "city", + "state", + "zip_code", + "country", +) +CASE_FIELDS = ("court_name", "docket_number", "case_title") +HINT_FIELDS = ( + "filing_type_name_hints", + "case_category_name_hints", + "case_type_name_hints", + "case_subtype_name_hints", + "document_type_name_hints", + "filing_component_name_hints", +) + + +class HandoffError(ValueError): + def __init__(self, message, *, status=400): + super().__init__(message) + self.status = status + + +def fingerprint(payload): + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _string(value, path, limit=255): + if not isinstance(value, str) or len(value) > limit: + raise HandoffError(f"{path} must be text of at most {limit} characters.") + return value + + +def _object(value, path): + if not isinstance(value, dict): + raise HandoffError(f"{path} must be an object.") + return value + + +def _hints(value, path): + for field in HINT_FIELDS: + if field in value: + hints = value[field] + if not isinstance(hints, list) or len(hints) > 20: + raise HandoffError(f"{path}.{field} must be a list of up to 20 names.") + for hint in hints: + _string(hint, field) + + +def validate_payload(payload, source_config, files): + _object(payload, "payload") + if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1: + raise HandoffError("Supported schema_version: 1.") + for field in ("source_id", "idempotency_key", "jurisdiction"): + if not _string(payload.get(field), field): + raise HandoffError(f"{field} is required.") + jurisdiction = payload["jurisdiction"] + if ( + jurisdiction not in source_config.get("jurisdictions", []) + or jurisdiction not in config_loader.get_available_jurisdictions() + ): + raise HandoffError("This source cannot send filings to that jurisdiction.", status=403) + _hints(payload, "payload") + _string(payload.get("filing_intent", ""), "filing_intent") + case = _object(payload.get("case", {}), "case") + if "existing_case" in case and type(case["existing_case"]) is not bool: + raise HandoffError("case.existing_case must be true or false when known.") + for field in CASE_FIELDS: + _string(case.get(field, ""), f"case.{field}", FilingDraft._meta.get_field(field).max_length) + _object(payload.get("known_filing_facts", {}), "known_filing_facts") + parties = payload.get("parties", []) + if not isinstance(parties, list) or len(parties) > 100: + raise HandoffError("parties must be a list of up to 100 people or organizations.") + for person in [_object(payload.get("filer", {}), "filer"), *parties]: + _object(person, "person") + for field in PERSON_FIELDS: + _string(person.get(field, ""), field, FilingParty._meta.get_field(field).max_length) + for field in ("is_self", "is_filing_party"): + if field in person and type(person[field]) is not bool: + raise HandoffError(f"{field} must be true or false.") + for field in ("semantic_role", "case_side_hint"): + _string(person.get(field, ""), field) + return_url = payload.get("return_url", "") + _string(return_url, "return_url", 2048) + if return_url: + try: + parts = urlsplit(return_url) + except ValueError as exc: + raise HandoffError("return_url must be a valid URL.") from exc + if ( + ( + parts.scheme != "https" + and not ( + settings.DEBUG + and parts.scheme == "http" + and source_config.get("allow_insecure_local_development") is True + ) + ) + or parts.username + or parts.password + or f"{parts.scheme}://{parts.netloc}" not in source_config.get("return_origins", []) + ): + raise HandoffError("return_url must use an allowed HTTPS origin.") + documents = payload.get("documents", []) + if not isinstance(documents, list) or len(documents) > 20: + raise HandoffError("documents must be a list of up to 20 PDFs.") + ids = set() + leads = 0 + for document in documents: + _object(document, "document") + key = _string(document.get("id"), "document.id", 100) + if not key or key in ids: + raise HandoffError("Each document needs a unique, nonempty id.") + ids.add(key) + if document.get("role") not in ("lead", "supporting"): + raise HandoffError("Document role must be lead or supporting.") + leads += document["role"] == "lead" + _string(document.get("form_name", ""), "form_name") + _hints(document, "document") + uploaded = files.get(key) + if uploaded is None: + raise HandoffError(f"Upload the PDF for document {key}.") + if uploaded.size > MAX_DOCUMENT_BYTES: + raise HandoffError("Each PDF must be at most 10 MB.") + digest = hashlib.sha256() + prefix = uploaded.read(5) + uploaded.seek(0) + if prefix != b"%PDF-": + raise HandoffError("Only PDF documents are accepted.") + for chunk in uploaded.chunks(): + digest.update(chunk) + uploaded.seek(0) + if digest.hexdigest() != document.get("sha256"): + raise HandoffError(f"Document hash mismatch: {key}.") + if leads > 1 or (documents and leads != 1): + raise HandoffError("A document bundle needs exactly one lead PDF.") + if set(files) != ids or any(len(files.getlist(key)) != 1 for key in files): + raise HandoffError("Upload each declared document exactly once.") + return payload + + +def receipt_for(draft): + while draft.correction_of_id: + draft = draft.correction_of + return InterviewHandoff.objects.filter(draft=draft).first() + + +def record(draft, path, kind, value): + FilingMetadataEvent.objects.create(draft=draft, path=path, kind=kind, value=value) + + +def _person(draft, values, role, order): + return FilingParty.objects.create( + draft=draft, + role=role, + sort_order=order, + **{key: values[key] for key in PERSON_FIELDS if key in values}, + is_self=values.get("is_self", False), + is_filing_party=values.get("is_filing_party", False), + party_role_hint=values.get("semantic_role", ""), + party_side=side_for_party_type_name(values.get("case_side_hint", "")), + ) + + +def populate(draft, payload, uploads): + case = payload.get("case", {}) + for field in CASE_FIELDS: + setattr(draft, field, case.get(field, "")) + if "existing_case" in case: + draft.existing_case = ExistingCase.EXISTING if case["existing_case"] else ExistingCase.NEW + # Keep arbitrary legal facts in the source receipt. Copy only supported + # questionnaire answers into the ordinary filing UI. + facts = payload.get("known_filing_facts", {}) + draft.supplemental_fields = {key: facts[key] for key in ("has_children", "child_count") if key in facts} + draft.save() + filer = payload.get("filer", {}) + if filer: + _person(draft, filer, "filer", 0) + for i, party in enumerate(payload.get("parties", [])): + # Explicit self identity can merge the caption row with the filer. + if party.get("is_self") and filer: + row = draft.parties.get(role="filer") + for field in PERSON_FIELDS: + if not getattr(row, field) and party.get(field): + setattr(row, field, party[field]) + row.is_self = True + row.is_filing_party = party.get("is_filing_party", True) + row.party_role_hint = party.get("semantic_role", "") + row.party_side = side_for_party_type_name(party.get("case_side_hint", "")) + row.save() + else: + _person(draft, party, "other", i) + for i, document in enumerate(payload.get("documents", [])): + uploaded = uploads[document["id"]] + row = FilingDocument.objects.create( + draft=draft, + role=document["role"], + sort_order=i, + name=document.get("form_name") or uploaded["filename"], + original_filename=uploaded["filename"], + size=uploaded["size"], + content_type="application/pdf", + s3_key=uploaded["key"], + public_url=uploaded["url"], + ) + record(draft, f"documents.{row.pk}", "source_suggestion", document) + record(draft, "handoff", "source_suggestion", payload) + + +def unique_match(options, hints): + wanted = {normalize_name(name) for name in hints if name} + matches = { + str(option["code"]): option + for option in options + if option.get("code") and normalize_name(option.get("name")) in wanted + } + return next(iter(matches.values())) if len(matches) == 1 else None + + +def court_hints(draft, payload): + hints = [draft.court_name] + # Vermont's court list calls each county a Unit; the legal interview calls + # it a Family Division. This is a semantic alias, never a cached court code. + county = payload.get("case", {}).get("county", "") + if draft.jurisdiction == "vermont" and isinstance(county, str) and county: + county = county.removesuffix(" County") + hints.append(f"{county} Unit") + return hints + + +def resolve_metadata(draft): + """Resolve only empty fields and only unambiguous live semantic matches.""" + receipt = receipt_for(draft) + if not receipt or draft.status != FilingDraft.Status.DRAFT: + return + payload = receipt.payload + configuration = config_loader.load_jurisdiction_config(draft.jurisdiction) or {} + intent = configuration.get("handoff", {}).get("filing_intents", {}).get(payload.get("filing_intent"), {}) + + def choose(obj, field, options, hints): + # Corrections stay blank until the filer chooses; don't restore the + # very suggestion the clerk returned. + path = ( + field + if obj is draft + else f"{'documents' if isinstance(obj, FilingDocument) else 'parties'}.{obj.pk}.{field}" + ) + if getattr(obj, field) or path in draft.correction_fields: + return + option = unique_match(options, hints) + if option: + setattr(obj, field, str(option["code"])) + 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"]) + del obj._metadata_kind + record( + draft, + path, + "live_resolution", + {"code": str(option["code"]), "name": option.get("name"), "source": "court metadata"}, + ) + + choose(draft, "court_code", _codes(draft.jurisdiction, "", with_names=True), court_hints(draft, payload)) + if not draft.court_code: + return + timing = "Subsequent" if draft.existing_case == ExistingCase.EXISTING else "Initial" + choose( + draft, + "case_category_code", + _codes(draft.jurisdiction, f"{draft.court_code}/categories", timing=timing, fileable_only=True), + intent.get("case_category_name_aliases", []) + payload.get("case_category_name_hints", []), + ) + if not draft.case_category_code: + return + choose( + draft, + "case_type_code", + _codes( + draft.jurisdiction, f"{draft.court_code}/case_types/", category_id=draft.case_category_code, timing=timing + ), + intent.get("case_type_name_aliases", []) + payload.get("case_type_name_hints", []), + ) + if not draft.case_type_code: + return + options = _codes( + draft.jurisdiction, + f"{draft.court_code}/filing_types/", + initial="false" if draft.existing_case == ExistingCase.EXISTING else "true", + category_id=draft.case_category_code, + type_id=draft.case_type_code, + ) + for document in draft.documents.all(): + suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() + hints = suggestion.value if suggestion else {} + curated = intent.get("documents", {}).get(hints.get("id", ""), {}) + choose( + document, + "filing_type_code", + options, + curated.get("filing_type_name_aliases", []) + + ( + hints.get("filing_type_name_hints", []) + or ( + intent.get("filing_type_name_aliases", []) + payload.get("filing_type_name_hints", []) + if document.role == "lead" + else [hints.get("form_name", "")] + ) + ), + ) + if document.filing_type_code: + for field, endpoint in ( + ("document_type_code", "document_types"), + ("filing_component_code", "filing_components"), + ): + choices = _codes( + draft.jurisdiction, f"{draft.court_code}/filing_types/{document.filing_type_code}/{endpoint}" + ) + choose(document, field, choices, hints.get(field.replace("_code", "_name_hints"), [])) + party_options = _codes(draft.jurisdiction, f"{draft.court_code}/case_types/{draft.case_type_code}/party_types") + for party in draft.parties.all(): + if party.role == "filer" and not (party.is_self or party.is_filing_party): + continue + hints = [party.party_role_hint] + exact = unique_match(party_options, hints) + candidates = ( + [option for option in party_options if side_for_party_type_name(option.get("name", "")) == party.party_side] + if party.party_side + else [] + ) + if not exact and len(candidates) == 1: + hints = [candidates[0]["name"]] + choose(party, "party_type", party_options, hints) + + +def issues_for(draft): + """Editable requirements, not a claim that the final EFSP payload is valid.""" + issues = [] + + def need(path, value, message, view): + if not value or path in draft.correction_fields: + issues.append({"code": f"{path.split('.')[-1]}_required", "path": path, "message": message, "view": view}) + + for field, label in ( + ("existing_case", "whether this starts a new case"), + ("court_code", "the court"), + ("case_category_code", "the case category"), + ("case_type_code", "the case type"), + ): + need(field, getattr(draft, field), f"Choose {label}.", "extraction_review") + if draft.existing_case == ExistingCase.EXISTING: + need("previous_case_id", draft.previous_case_id, "Find and confirm the existing court case.", "case_lookup") + need("main_document", draft.documents.filter(role="lead").exists(), "Add the main PDF.", "upload_documents") + need( + "document_checklist_acknowledged", + draft.document_checklist_acknowledged, + "Check that all required documents are included.", + "document_checklist", + ) + 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"), + ): + need( + f"documents.{doc.pk}.{field}", + getattr(doc, field), + f"Choose the {label} for {doc.name}.", + "organize_documents", + ) + filer = draft.parties.filter(role="filer").first() + for field in ("first_name", "last_name", "address_line_1", "city", "state", "zip_code", "email"): + need(f"filer.{field}", getattr(filer, field, ""), f"Add your {field.replace('_', ' ')}.", "your_information") + need( + "filing_party", draft.parties.filter(is_filing_party=True).exists(), "Choose who you are filing for.", "parties" + ) + for party in draft.parties.all(): + if party.role != "filer" or party.is_self or party.is_filing_party: + need( + f"parties.{party.pk}.party_type", + party.party_type, + f"Choose the court's party type for {party}.", + "parties", + ) + from efile.services.party_requirements import address_is_blank, address_is_complete, party_address_requirement + from efile.services.people import get_case_questions, needs_amount_in_controversy + + for party in draft.parties.exclude(role="filer"): + need( + f"parties.{party.pk}.name", + party.organization_name or (party.first_name and party.last_name), + f"Add the name for this party: {party}.", + "parties", + ) + address_ok = address_is_complete(party) or ( + address_is_blank(party) and not party_address_requirement(draft, party).required + ) + need(f"parties.{party.pk}.address", address_ok, f"Complete the address for {party}.", "parties") + for question in get_case_questions(draft): + if question.get("required"): + answered = draft.supplemental_fields.get(question["name"]) not in (None, "") + need(f"known_filing_facts.{question['name']}", answered, question["label"], "case_questions") + if needs_amount_in_controversy(draft): + need("amount_in_controversy", draft.amount_in_controversy, "Enter the amount in controversy.", "case_questions") + need( + "selected_payment_account_id", + draft.selected_payment_account_id, + "Choose a payment method or fee waiver and check fees.", + "payment", + ) + if "documents" in draft.correction_fields and draft.documents.exists(): + need("documents", False, "Replace the PDF the clerk asked you to correct.", "upload_documents") + return issues + + +def full_snapshot(draft): + data = model_to_dict(draft, exclude=["submission_snapshot"]) + data["documents"] = [model_to_dict(doc) for doc in draft.documents.all()] + data["parties"] = [model_to_dict(party) for party in draft.parties.all()] + return json.loads(json.dumps(data, cls=DjangoJSONEncoder)) + + +@transaction.atomic +def create_correction(draft, detail, fields): + original = FilingDraft.objects.select_for_update().get(pk=draft.pk) + if original.status != FilingDraft.Status.SUBMITTED or detail.get("status", "").strip().lower() not in { + "rejected", + "returned", + }: + raise HandoffError("Only a confirmed clerk return can be corrected. Check the filing status first.", status=409) + existing = FilingDraft.objects.filter(correction_of=original).first() + if existing: + return existing + if not fields: + raise HandoffError("Choose what the clerk asked you to correct.") + allowed = {"court_code", "case_category_code", "case_type_code", "documents"} + allowed.update( + f"documents.{doc.pk}.{field}" + for doc in original.documents.all() + for field in ("filing_type_code", "document_type_code", "filing_component_code") + ) + allowed.update(f"parties.{party.pk}.party_type" for party in original.parties.all()) + if not set(fields) <= allowed: + raise HandoffError("Unknown correction field.") + # Older submissions did not yet capture a snapshot. Freeze one before any + # copying; never update the original answers or response. + if not original.submission_snapshot: + FilingDraft.objects.filter(pk=original.pk).update(submission_snapshot=full_snapshot(original)) + values = model_to_dict( + original, exclude=["id", "correction_of", "submission_snapshot", "clerk_return", "correction_fields"] + ) + values["user_id"] = values.pop("user") + values["plan_id"] = values.pop("plan") + values.update( + status=FilingDraft.Status.DRAFT, + current_step=WorkflowStepKey.REVIEW, + submitted_at=None, + submission_response={}, + quoted_fee_total="", + quoted_fee_breakdown=[], + selected_payment_account_id="", + selected_payment_account_name="", + selected_payment_account_type="", + ) + revision = FilingDraft.objects.create(**values, correction_of=original, clerk_return=detail) + mapping = {} + for relation in ("documents", "parties"): + for row in getattr(original, relation).all(): + old_id = row.pk + row.pk = None + row.draft = revision + row.save() + mapping[f"{relation}.{old_id}"] = f"{relation}.{row.pk}" + for event in original.metadata_events.all(): + path = event.path + for old, new in mapping.items(): + if path == old or path.startswith(old + "."): + path = new + path[len(old) :] + break + record(revision, path, event.kind, event.value) + revised_fields = [] + for path in fields: + for old, new in mapping.items(): + if path.startswith(old + "."): + path = new + path[len(old) :] + break + revised_fields.append(path) + parts = path.split(".") + if len(parts) == 3: + obj = getattr(revision, parts[0]).get(pk=parts[1]) + setattr(obj, parts[2], "") + name_field = parts[2].replace("_code", "_name") if parts[2] != "party_type" else "party_type_name" + setattr(obj, name_field, "") + if parts[0] == "documents" and parts[2] == "filing_type_code": + obj.document_type_code = "" + obj.document_type_name = "" + obj.filing_component_code = "" + obj.filing_component_name = "" + obj.requested_optional_services = [] + obj.save() + elif path != "documents": + setattr(revision, path, "") + setattr(revision, path.replace("_code", "_name"), "") + record(revision, path, "clerk_correction", {"returned_at": timezone.now().isoformat(), "detail": detail}) + # Dependent court metadata must be chosen again after changing its scope. + if any(field in fields for field in ("court_code", "case_category_code", "case_type_code")): + if "court_code" in fields: + revision.case_category_code = "" + revision.case_type_code = "" + revision.documents.update( + filing_type_code="", document_type_code="", filing_component_code="", requested_optional_services=[] + ) + revision.parties.update(party_type="") + revision.optional_services = [] + revision.correction_fields = revised_fields + revision.save() + return revision + + +def submitted_filing_ids(response): + """Read only filing identifiers, never mistake an envelope ID for one.""" + ids = set() + if isinstance(response, dict): + for key, value in response.items(): + if key in {"filing_id", "filingId", "filingID"} and isinstance(value, str | int) and value: + ids.add(str(value)) + elif key in {"filingIds", "filingIDs", "filing_ids"} and isinstance(value, list): + ids.update(str(item) for item in value if type(item) in (str, int) and item) + elif isinstance(value, dict | list): + ids.update(submitted_filing_ids(value)) + category = response.get("identificationCategory", {}) + if isinstance(category, dict) and category.get("value") == "FILINGID": + value = response.get("identificationID", {}) + if isinstance(value, dict): + value = value.get("value") + if value: + ids.add(str(value)) + elif isinstance(response, list): + for value in response: + ids.update(submitted_filing_ids(value)) + return ids + + +def local_submission(user, jurisdiction, court_code, filing_id): + for draft in FilingDraft.objects.filter( + user=user, jurisdiction=jurisdiction, court_code=court_code, status=FilingDraft.Status.SUBMITTED + ): + if str(filing_id) in submitted_filing_ids(draft.submission_response): + return draft + return None + + +def matter_keys(user, jurisdiction, remote_filings): + """Keep correction attempts in one filer-visible matter before case indexing.""" + drafts = list(FilingDraft.objects.filter(user=user, jurisdiction=jurisdiction).select_related("correction_of")) + by_id = {draft.pk: draft for draft in drafts} + roots = {} + for draft in drafts: + root = draft + while root.correction_of_id and root.correction_of_id in by_id: + root = by_id[root.correction_of_id] + for filing_id in submitted_filing_ids(draft.submission_response): + roots[filing_id] = root.pk + tracked = {} + for filing in remote_filings: + root = roots.get(str(filing.get("filing_id", ""))) + if root and filing.get("case_tracking_id"): + tracked[root] = str(filing["case_tracking_id"]) + return {filing_id: tracked.get(root, f"matter:{root}") for filing_id, root in roots.items()} diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index 7f53bd73..0c110feb 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path @@ -185,3 +186,9 @@ }, }, } + +# Per-source bearer credentials; never send these to the browser. +# {"vermont-rfa": {"token": "...", "jurisdictions": ["vermont"], +# "return_origins": ["https://interviews.example.org"]}} +LITEFILE_HANDOFF_SOURCES = json.loads(os.getenv("LITEFILE_HANDOFF_SOURCES", "{}")) +LITEFILE_HANDOFF_TOKEN_MAX_AGE = 86400 diff --git a/efile_app/efile/signals.py b/efile_app/efile/signals.py index 382998d3..c3a83148 100644 --- a/efile_app/efile/signals.py +++ b/efile_app/efile/signals.py @@ -11,3 +11,57 @@ def synchronize_deleted_document(sender, instance, **kwargs): draft = FilingDraft.objects.filter(pk=instance.draft_id).first() if draft is not None: sync_primary_filing_type(draft) + + +# Metadata provenance is recorded for ordinary filing screens as well as the +# handoff screen. Source suggestions stay in the receipt when a filer overrides +# them. Pure summary synchronization intentionally uses QuerySet.update instead. +from django.db.models.signals import post_save, pre_save # noqa: E402 + +from efile.models import FilingParty # noqa: E402 + +_METADATA_FIELDS = { + FilingDraft: ("court_code", "case_category_code", "case_type_code", "case_subtype_code"), + FilingDocument: ("filing_type_code", "document_type_code", "filing_component_code", "requested_optional_services"), + FilingParty: ("party_type",), +} + + +@receiver(pre_save, sender=FilingDraft) +@receiver(pre_save, sender=FilingDocument) +@receiver(pre_save, sender=FilingParty) +def remember_metadata_before_edit(sender, instance, **kwargs): + if not instance.pk: + instance._metadata_before = None + return + instance._metadata_before = sender.objects.filter(pk=instance.pk).values(*_METADATA_FIELDS[sender]).first() + + +@receiver(post_save, sender=FilingDraft) +@receiver(post_save, sender=FilingDocument) +@receiver(post_save, sender=FilingParty) +def record_metadata_edit(sender, instance, update_fields=None, **kwargs): + from efile.services.handoff import receipt_for, record + + before = getattr(instance, "_metadata_before", None) + if before is None or getattr(instance, "_metadata_kind", "") == "live_resolution": + return + changes = { + field: {"before": previous, "after": getattr(instance, field)} + for field, previous in before.items() + if (update_fields is None or field in update_fields) and previous != getattr(instance, field) + } + if not changes: + return + draft = instance if sender is FilingDraft else instance.draft + if not draft.correction_of_id and not receipt_for(draft): + return + for field, value in changes.items(): + prefix = ( + "" if sender is FilingDraft else f"{'documents' if sender is FilingDocument else 'parties'}.{instance.pk}." + ) + path = prefix + field + record(draft, path, "user_edit", value) + if value["after"] and path in draft.correction_fields: + draft.correction_fields = [item for item in draft.correction_fields if item != path] + FilingDraft.objects.filter(pk=draft.pk).update(correction_fields=draft.correction_fields) diff --git a/efile_app/efile/static/config/states/vermont.yaml b/efile_app/efile/static/config/states/vermont.yaml index d566bf13..7a7db488 100644 --- a/efile_app/efile/static/config/states/vermont.yaml +++ b/efile_app/efile/static/config/states/vermont.yaml @@ -160,3 +160,18 @@ court_selector: no_match_hint: >- We could not match that town or ZIP code to a unit. Check the spelling, or choose the unit from the list above. + +# Interview hints remain suggestions. Only unique live-name matches preselect. +handoff: + filing_intents: + relief_from_abuse: + case_category_name_aliases: [Family] + documents: + RFAaffidavit: + filing_type_name_aliases: [Affidavit] + RFAaffidavitonbehalfofminor: + filing_type_name_aliases: [Affidavit] + RFAconfidentialcontactinfo: + filing_type_name_aliases: [Relief from Abuse Litigant Address Form] + RFAserviceinfo: + filing_type_name_aliases: ["Protection Order Service Information DPS #132"] diff --git a/efile_app/efile/templates/efile/confirmation.html b/efile_app/efile/templates/efile/confirmation.html index ecbca3ab..a2183971 100644 --- a/efile_app/efile/templates/efile/confirmation.html +++ b/efile_app/efile/templates/efile/confirmation.html @@ -8,6 +8,7 @@ {% endblock extra_css %} {% block workflow_content %} +

Check for a clerk return and correct this filing

diff --git a/efile_app/efile/templates/efile/correct_filing.html b/efile_app/efile/templates/efile/correct_filing.html new file mode 100644 index 00000000..49041382 --- /dev/null +++ b/efile_app/efile/templates/efile/correct_filing.html @@ -0,0 +1,15 @@ +{% extends "efile/site_base.html" %} +{% block title %}Correct and resubmit{% endblock title %} +{% block public_content %} +

Correct and resubmit

+

The court returned this filing. Select the details the clerk asked you to correct. Your other answers and documents will stay in the same matter.

+ {% for comment in detail.comments %}

{{ comment.text }}

{% endfor %} +
+ {% csrf_token %} +
+ What needs a correction? + {% for value, label in choices %}
{% endfor %} +
+ +
+{% endblock public_content %} diff --git a/efile_app/efile/templates/efile/filing_detail.html b/efile_app/efile/templates/efile/filing_detail.html index 0f24242a..93b53cb3 100644 --- a/efile_app/efile/templates/efile/filing_detail.html +++ b/efile_app/efile/templates/efile/filing_detail.html @@ -68,6 +68,9 @@

{% endif %}

+ {% if local_draft and filing.status_presentation.tone == 'rejected' %} +

{% translate "Correct and resubmit" %}

+ {% endif %} {% for comment in filing.comments %}

{{ comment.heading }}

diff --git a/efile_app/efile/templates/efile/handoff_claim.html b/efile_app/efile/templates/efile/handoff_claim.html new file mode 100644 index 00000000..9cdf7623 --- /dev/null +++ b/efile_app/efile/templates/efile/handoff_claim.html @@ -0,0 +1,15 @@ +{% extends "efile/site_base.html" %} +{% block title %}Continue your filing{% endblock title %} +{% block public_content %} + {% if expired %} +

This filing link has expired

+

Return to your interview and choose “Continue in LITEFile” again.

+ {% elif unavailable %} +

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 to LITEFile. Add this draft to your signed-in account to check the filing details and send it to court.

+
{% 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 new file mode 100644 index 00000000..a1bb4716 --- /dev/null +++ b/efile_app/efile/templates/efile/handoff_review.html @@ -0,0 +1,30 @@ +{% extends "efile/site_base.html" %} +{% block title %}Continue your filing{% endblock title %} +{% block public_content %} +

{{ draft.case_title|default:"Continue your filing" }}

+ {% if closed %} +

This filing is {{ draft.get_status_display|lower }}.

+ {% if draft.status == 'submitted' %}Check for a clerk return and correct this filing{% endif %} + {% else %} + {% if draft.correction_of_id %}

You are correcting the same matter. Your previous submission and documents are preserved.

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

{{ comment.text }}

{% endfor %} +

Your interview answers and documents are saved. Complete the missing details below. You can edit any answer that needs a correction.

+
{% csrf_token %}
+

Details to complete

+
    {% for issue in issues %}
  • {{ issue.message }}
  • {% empty %}
  • Your draft is ready for the final filing checks.
  • {% endfor %}
+
{% csrf_token %}
+

Saved case information

+

{{ draft.court_name }} {{ draft.docket_number }} {{ draft.case_type_name }}

+ Edit case details +

People

+
    {% for party in parties %}
  • {{ party }} — {{ party.party_role_hint }} {{ party.party_type_name }}
  • {% endfor %}
+ Edit people and party types +

Documents

+
    {% for document in documents %}
  • {{ document.name }} — {{ document.filing_type_name }}
  • {% endfor %}
+ Edit documents and filing types + {% if can_return %} +
{% csrf_token %}
+ {% endif %} +

Review and submit

+ {% endif %} +{% endblock public_content %} diff --git a/efile_app/efile/templates/efile/workflow_base.html b/efile_app/efile/templates/efile/workflow_base.html index aba6b6df..951ccd28 100644 --- a/efile_app/efile/templates/efile/workflow_base.html +++ b/efile_app/efile/templates/efile/workflow_base.html @@ -28,6 +28,9 @@
{% include "efile/components/workflow_progress.html" %} {% include "efile/components/inline_messages.html" %} + {% if handoff_draft_id %} +

{% translate "Return to saved interview answers and corrections" %}

+ {% endif %} {% block workflow_content %} {% endblock workflow_content %}
diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py new file mode 100644 index 00000000..e2703abc --- /dev/null +++ b/efile_app/efile/tests/test_handoff.py @@ -0,0 +1,419 @@ +import hashlib +import json +from unittest.mock import patch + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from efile.models import FilingDraft, InterviewHandoff +from efile.services.handoff import HandoffError, create_correction, resolve_metadata, unique_match + +pytestmark = pytest.mark.django_db +PDF = b"%PDF-1.4\nsynthetic test document" + + +@pytest.fixture +def source(settings): + settings.LITEFILE_HANDOFF_SOURCES = { + "rfa": {"token": "secret", "jurisdictions": ["vermont"], "return_origins": ["https://interviews.example.org"]} + } + return {"HTTP_X_LITEFILE_SOURCE": "rfa", "HTTP_AUTHORIZATION": "Bearer secret"} + + +@pytest.fixture +def payload(): + return { + "schema_version": 1, + "source_id": "session-opaque", + "idempotency_key": "retry-key", + "jurisdiction": "vermont", + "case": {"existing_case": False, "court_name": "Test family court", "court_code": "untrusted"}, + "filing_intent": "relief_from_abuse", + "filing_type_name_hints": ["Complaint"], + "case_category_name_hints": ["Family"], + "case_type_name_hints": ["Relief from abuse"], + "filer": {"first_name": "Test", "last_name": "Filer", "email": "filer@example.org"}, + "parties": [ + { + "first_name": "Test", + "last_name": "Filer", + "is_self": True, + "is_filing_party": True, + "semantic_role": "plaintiff", + "case_side_hint": "plaintiff", + } + ], + "documents": [ + { + "id": "complaint", + "role": "lead", + "form_name": "RFA complaint", + "sha256": hashlib.sha256(PDF).hexdigest(), + "filing_type_code": "9999", + } + ], + "return_url": "https://interviews.example.org/interview?session=opaque", + } + + +def send(client, source, payload, data=PDF): + return client.post( + reverse("external_handoff"), + {"payload": json.dumps(payload), "complaint": SimpleUploadedFile("complaint.pdf", data, "application/pdf")}, + **source, + ) + + +@pytest.fixture +def storage(): + with patch("efile.views.handoff.S3UploadHandler") as mocked: + mocked.return_value.upload_file.return_value = { + "success": True, + "key": "private/key.pdf", + "url": "https://s3.example/signed", + "filename": "complaint.pdf", + "size": len(PDF), + } + yield mocked.return_value + + +def login(client, django_user_model, username="filer"): + user = django_user_model.objects.create_user(username=username, tyler_jurisdiction="vermont") + client.force_login(user) + session = client.session + session["auth_tokens"] = {"TYLER-TOKEN-VERMONT": "test-token"} + session.save() + return user + + +def test_partial_draft_idempotency_and_source_hints(client, source, payload, storage): + first = send(client, source, payload) + assert first.status_code == 201, first.content + assert first.json()["state"] == "needs_input" + draft = FilingDraft.objects.get() + assert draft.user is None + assert draft.court_name == "Test family court" + assert draft.court_code == "" + assert draft.documents.get().filing_type_code == "" + assert draft.parties.count() == 1 + assert draft.parties.get().email == "filer@example.org" + assert send(client, source, payload).json()["draft_id"] == first.json()["draft_id"] + assert storage.upload_file.call_count == 1 + payload["case"]["court_name"] = "A different court" + assert send(client, source, payload).status_code == 409 + assert FilingDraft.objects.count() == 1 + + +def test_auth_hash_schema_and_scope(client, source, payload, storage): + assert send(client, {}, payload).status_code == 401 + assert send(client, source, payload, b"%PDF-tampered").status_code == 400 + payload["schema_version"] = 2 + assert send(client, source, payload).status_code == 400 + payload["schema_version"] = 1 + payload["jurisdiction"] = "illinois" + assert send(client, source, payload).status_code == 403 + assert not FilingDraft.objects.exists() + storage.upload_file.assert_not_called() + + +def test_claim_requires_explicit_post_and_enforces_owner(client, source, payload, storage, django_user_model): + url = send(client, source, payload).json()["continue_url"] + assert client.get(url).status_code == 302 + user = login(client, django_user_model) + assert client.get(url).status_code == 200 + assert FilingDraft.objects.get().user_id is None + with patch("efile.services.handoff._codes", return_value=[]): + assert client.post(url).status_code == 302 + draft = FilingDraft.objects.get() + assert draft.user_id == user.pk + login(client, django_user_model, "other") + assert client.post(url).status_code == 403 + assert client.get(reverse("handoff_review", args=[draft.pk])).status_code == 404 + + +def test_ambiguous_names_do_not_preselect(): + assert unique_match([{"code": "a", "name": "Complaint"}, {"code": "b", "name": "Complaint"}], ["Complaint"]) is None + + +def test_live_resolution_and_user_override_provenance(client, source, payload, storage): + send(client, source, payload) + draft = FilingDraft.objects.get() + + def codes(jurisdiction, path, **params): + return { + "": [{"code": "vt", "name": "Test family court"}], + "vt/categories": [{"code": "fam", "name": "Family"}], + "vt/case_types/": [{"code": "rfa", "name": "Relief from abuse"}], + "vt/filing_types/": [{"code": "live", "name": "Complaint"}], + }.get(path, []) + + with patch("efile.services.handoff._codes", side_effect=codes): + resolve_metadata(draft) + doc = draft.documents.get() + assert doc.filing_type_code == "live" + assert draft.metadata_events.filter(kind="live_resolution").exists() + doc.filing_type_code = "user-selected" + doc.save() + assert draft.metadata_events.filter(kind="user_edit", path=f"documents.{doc.pk}.filing_type_code").exists() + assert InterviewHandoff.objects.get().payload["documents"][0]["filing_type_code"] == "9999" + + +def test_correction_preserves_snapshot_and_blocks_ambiguous_attempts( + client, source, payload, storage, django_user_model +): + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + doc = draft.documents.get() + doc.filing_type_code = "old" + doc.save() + with pytest.raises(HandoffError): + create_correction(draft, {"status": "rejected"}, [f"documents.{doc.pk}.filing_type_code"]) + draft.mark_submitted({"filing_id": "confirmed"}) + snapshot = draft.submission_snapshot + with pytest.raises(HandoffError): + create_correction(draft, {"status": "pending"}, ["documents"]) + revision = create_correction(draft, {"status": "rejected"}, [f"documents.{doc.pk}.filing_type_code"]) + assert revision.documents.get().s3_key == doc.s3_key + assert revision.documents.get().filing_type_code == "" + assert revision.parties.get().first_name == "Test" + draft.refresh_from_db() + assert draft.submission_snapshot == snapshot + assert draft.documents.get().filing_type_code == "old" + assert create_correction(draft, {"status": "rejected"}, ["documents"]).pk == revision.pk + assert revision.selected_payment_account_id == "" + + +def test_replacement_retains_metadata_and_is_idempotent(client, source, payload, storage, django_user_model): + from urllib.parse import parse_qs, urlsplit + + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + doc = draft.documents.get() + doc.filing_type_code = "user-code" + doc.save() + response = client.post(reverse("return_to_interview", args=[draft.pk])) + token = parse_qs(urlsplit(response.url).query)["litefile_correction"][0] + payload["idempotency_key"] = "replace-1" + payload["case"]["court_name"] = "Must not overwrite" + headers = {**source, "HTTP_X_LITEFILE_CORRECTION": token} + + def replace(): + return client.post( + reverse("handoff_replace_documents"), + {"payload": json.dumps(payload), "complaint": SimpleUploadedFile("complaint.pdf", PDF)}, + **headers, + ) + + assert replace().status_code == 200 + assert replace().status_code == 200 + draft.refresh_from_db() + assert draft.court_name == "Test family court" + assert draft.documents.get().filing_type_code == "user-code" + assert storage.upload_file.call_count == 2 + + +def test_expired_claim_and_csrf(client, source, payload, storage, django_user_model, settings): + from django.test import Client + + url = send(client, source, payload).json()["continue_url"] + csrf_client = Client(enforce_csrf_checks=True) + user = login(csrf_client, django_user_model) + assert csrf_client.post(url).status_code == 403 + settings.LITEFILE_HANDOFF_TOKEN_MAX_AGE = -1 + assert client.get(url).status_code == 410 + assert FilingDraft.objects.get().user_id is None + assert user.is_authenticated + + +def test_storage_failure_rolls_back_and_retry_can_succeed(client, source, payload, storage): + storage.upload_file.return_value = {"success": False} + assert send(client, source, payload).status_code == 503 + assert not FilingDraft.objects.exists() + assert not InterviewHandoff.objects.exists() + + +def test_partial_without_documents_is_durable(client, source, payload, storage): + payload.pop("documents") + payload.pop("filer") + payload.pop("parties") + response = client.post(reverse("external_handoff"), json.dumps(payload), content_type="application/json", **source) + assert response.status_code == 201 + assert any(issue["path"] == "main_document" for issue in response.json()["issues"]) + storage.upload_file.assert_not_called() + + +def test_targeted_edit_returns_to_handoff(client, source, payload, storage, django_user_model): + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + response = client.post( + reverse("your_information", kwargs={"jurisdiction": "vermont"}) + f"?draft={draft.pk}&return_to=handoff", + { + "first_name": "Corrected", + "last_name": "Filer", + "address_line_1": "100 Main Street", + "city": "Burlington", + "state": "VT", + "zip_code": "05401", + "email": "filer@example.org", + }, + ) + assert response.status_code == 302 + assert response.url == reverse("handoff_review", args=[draft.pk]) + assert draft.parties.get().first_name == "Corrected" + assert draft.documents.count() == 1 + + +def test_replacement_cannot_edit_a_submitted_attempt(client, source, payload, storage, django_user_model): + from urllib.parse import parse_qs, urlsplit + + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + response = client.post(reverse("return_to_interview", args=[draft.pk])) + token = parse_qs(urlsplit(response.url).query)["litefile_correction"][0] + draft.mark_submitted({"filing_id": "submitted"}) + payload["idempotency_key"] = "later-update" + response = client.post( + reverse("handoff_replace_documents"), + {"payload": json.dumps(payload), "complaint": SimpleUploadedFile("complaint.pdf", PDF)}, + **source, + HTTP_X_LITEFILE_CORRECTION=token, + ) + assert response.status_code == 409 + assert storage.upload_file.call_count == 1 + + +def test_correction_api_rejects_partly_accepted_submission(client, django_user_model): + user = login(client, django_user_model) + draft = FilingDraft.objects.create( + user=user, + jurisdiction="vermont", + court_code="vt", + status="submitted", + submission_response={"filings": [{"filing_id": "returned"}, {"filing_id": "accepted"}]}, + ) + with ( + patch("efile.views.handoff.fetch_filing_detail", return_value={}), + patch( + "efile.views.handoff.describe_filing_detail", side_effect=[{"filing_id": "accepted", "status": "accepted"}] + ), + ): + response = client.post(reverse("correct_filing", args=[draft.pk]), {"fields": ["documents"]}) + assert response.status_code == 409 + assert not FilingDraft.objects.filter(correction_of=draft).exists() + + +def test_correction_attempts_share_the_remote_case_group(django_user_model): + from efile.services.handoff import matter_keys + + user = django_user_model.objects.create_user(username="grouped", tyler_jurisdiction="vermont") + old = FilingDraft.objects.create( + user=user, jurisdiction="vermont", status="submitted", submission_response={"filing_id": "old"} + ) + FilingDraft.objects.create( + user=user, + jurisdiction="vermont", + correction_of=old, + status="submitted", + submission_response={"filing_id": "new"}, + ) + keys = matter_keys(user, "vermont", [{"filing_id": "old"}, {"filing_id": "new", "case_tracking_id": "real-case"}]) + assert keys == {"old": "real-case", "new": "real-case"} + + +def test_unclaimed_retention_leaves_claimed_drafts(client, source, payload, storage, django_user_model): + from datetime import timedelta + from io import StringIO + + from django.core.management import call_command + from django.utils import timezone + + send(client, source, payload) + InterviewHandoff.objects.update(created_at=timezone.now() - timedelta(days=8)) + output = StringIO() + call_command("expire_unclaimed_handoffs", stdout=output) + assert "Would expire 1" in output.getvalue() + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + call_command("expire_unclaimed_handoffs", apply=True, stdout=output) + assert FilingDraft.objects.filter(pk=draft.pk).exists() + + +def test_imported_pdf_access_urls_are_renewed(client, source, payload, storage): + from efile.services.drafts import read_upload_data + + send(client, source, payload) + draft = FilingDraft.objects.get() + with patch("efile.utils.s3_upload_handler.S3UploadHandler") as handler: + handler.return_value._ensure_initialized.return_value = True + handler.return_value.get_public_url.return_value = "https://s3.example/fresh-signature" + uploads = read_upload_data(draft) + assert uploads["files"]["lead"]["url"] == "https://s3.example/fresh-signature" + assert draft.documents.get().public_url == "https://s3.example/signed" + + +def test_claim_referrer_policy_allows_same_origin_forms(client, source, payload, storage, django_user_model): + url = send(client, source, payload).json()["continue_url"] + login(client, django_user_model) + response = client.get(url) + # no-referrer makes browser form navigations send Origin: null, which + # Django correctly rejects. same-origin still hides the capability URL + # from external sites while allowing the claim's CSRF-protected POST. + assert response["Referrer-Policy"] == "same-origin" + assert response["Cache-Control"] == "no-store" + + +def test_malformed_return_url_is_validation_error(client, source, payload): + payload["return_url"] = "https://[invalid" + response = send(client, source, payload) + assert response.status_code == 400 + assert not InterviewHandoff.objects.exists() + + +def test_pdf_correction_does_not_report_existing_lead_as_missing(client, source, payload, storage): + from efile.services.handoff import issues_for + + response = send(client, source, payload) + draft = FilingDraft.objects.get(pk=response.json()["draft_id"]) + draft.correction_fields = ["documents"] + messages = [issue["message"] for issue in issues_for(draft)] + assert "Add the main PDF." not in messages + assert "Replace the PDF the clerk asked you to correct." in messages + + +def test_dev_submission_filing_ids_link_status_and_corrections(django_user_model): + from efile.services.handoff import local_submission, matter_keys, submitted_filing_ids + + user = django_user_model.objects.create_user(username="submitted-filer") + response = {"caseId": "case-1", "envelopeId": "envelope-1", "filingIds": ["filing-1", "filing-2"]} + draft = FilingDraft.objects.create(user=user, jurisdiction="vermont", court_code="test-court") + draft.mark_submitted(response) + assert submitted_filing_ids(response) == {"filing-1", "filing-2"} + assert local_submission(user, "vermont", "test-court", "filing-2") == draft + assert matter_keys(user, "vermont", []) == {"filing-1": f"matter:{draft.pk}", "filing-2": f"matter:{draft.pk}"} + assert submitted_filing_ids({"caseId": "case-1", "envelopeId": "envelope-1"}) == set() + + +def test_dev_submission_confirmation_uses_envelope_reference(): + from efile.views.confirmation import _confirmation_number + + assert ( + _confirmation_number( + { + "caseId": "case-1", + "envelopeId": "envelope-1", + "filingIds": ["filing-1", "filing-2"], + "leadContact": {"id": "contact-1"}, + } + ) + == "envelope-1" + ) diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 3cf97f49..3287d4b3 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -16,6 +16,14 @@ from .views.extraction_review import extraction_review from .views.filing_path import filing_path from .views.filing_plans import filing_plans +from .views.handoff import ( + correct_filing, + external_handoff, + handoff_claim, + handoff_review, + replace_documents, + return_to_interview, +) 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 @@ -54,6 +62,12 @@ def jurisdiction_homepage(request, jurisdiction): urlpatterns = [ + path("api/handoffs/v1/", external_handoff, name="external_handoff"), + 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//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"), path("review/", include("crosswalk_review.urls")), path("", homepage, name="home"), diff --git a/efile_app/efile/views/confirmation.py b/efile_app/efile/views/confirmation.py index a9932c62..b8c8ee39 100644 --- a/efile_app/efile/views/confirmation.py +++ b/efile_app/efile/views/confirmation.py @@ -12,7 +12,15 @@ def _confirmation_number(response): if isinstance(response, dict): - for key in ("confirmation_number", "confirmationNumber", "filing_id", "filingId", "id"): + for key in ( + "confirmation_number", + "confirmationNumber", + "envelopeId", + "envelope_id", + "filing_id", + "filingId", + "id", + ): if response.get(key): return str(response[key]) values = response.values() diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py new file mode 100644 index 00000000..81ea4c4e --- /dev/null +++ b/efile_app/efile/views/handoff.py @@ -0,0 +1,415 @@ +"""Source-authenticated API and filer-owned continuation/correction screens.""" + +import json +import secrets +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import requests +from django.conf import settings +from django.core import signing +from django.db import IntegrityError, transaction +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDraft, HandoffDocumentUpdate, InterviewHandoff +from efile.services.current_drafts import attach_current_draft +from efile.services.draft_urls import draft_url +from efile.services.filings import describe_filing_detail, fetch_filing_detail +from efile.services.handoff import ( + HandoffError, + create_correction, + fingerprint, + issues_for, + populate, + receipt_for, + record, + resolve_metadata, + submitted_filing_ids, + validate_payload, +) +from efile.utils.s3_upload_handler import S3UploadHandler +from efile.workflow import WorkflowStepKey + +CLAIM_SALT = "litefile.handoff.claim.v1" +REPLACE_SALT = "litefile.handoff.replace.v1" + + +def _source(request): + source = request.headers.get("X-LITEFile-Source", "") + config = settings.LITEFILE_HANDOFF_SOURCES.get(source, {}) + expected = config.get("token", "") + supplied = request.headers.get("Authorization", "") + if not expected or not secrets.compare_digest(supplied.encode(), f"Bearer {expected}".encode()): + raise HandoffError("Source authentication required.", status=401) + return source, config + + +def _read(request, config): + try: + raw = request.POST.get("payload", "") if request.content_type == "multipart/form-data" else request.body + if len(raw) > 512 * 1024: + raise HandoffError("Handoff metadata exceeds 512 KB.", status=413) + payload = json.loads(raw) + except (ValueError, UnicodeDecodeError) as exc: + raise HandoffError("Send a JSON payload, with multipart PDF files when present.") from exc + return validate_payload(payload, config, request.FILES) + + +def _response(request, receipt, *, created=False): + draft = receipt.draft + token = signing.dumps({"receipt": receipt.pk}, salt=CLAIM_SALT) + return JsonResponse( + { + "draft_id": str(draft.pk), + "state": "needs_input", + "issues": issues_for(draft), + "continue_url": request.build_absolute_uri(reverse("handoff_claim", args=[token])), + }, + status=201 if created else 200, + ) + + +def _upload(payload, files, handler, keys): + uploads = {} + for document in payload.get("documents", []): + result = handler.upload_file( + files[document["id"]], file_type=document["role"], metadata={"sha256": document["sha256"]} + ) + if not result.get("success"): + raise HandoffError("Document storage is unavailable. Retry the same handoff.", status=503) + keys.append(result["key"]) + uploads[document["id"]] = result + return uploads + + +@csrf_exempt +@require_http_methods(["POST"]) +def external_handoff(request): + handler = S3UploadHandler() + keys = [] + try: + source, config = _source(request) + payload = _read(request, config) + digest = fingerprint(payload) + existing = InterviewHandoff.objects.filter(source=source, source_id=payload["source_id"]).first() + if existing: + if existing.fingerprint != digest: + raise HandoffError( + "This source_id already has a different handoff. Use the document-correction flow.", status=409 + ) + return _response(request, existing) + try: + with transaction.atomic(): + draft = FilingDraft.objects.create( + jurisdiction=payload["jurisdiction"], current_step=WorkflowStepKey.CASE_CONFIRMATION + ) + receipt = InterviewHandoff.objects.create( + draft=draft, + source=source, + source_id=payload["source_id"], + idempotency_key=payload["idempotency_key"], + fingerprint=digest, + payload=payload, + ) + uploads = _upload(payload, request.FILES, handler, keys) + populate(draft, payload, uploads) + except IntegrityError: + for key in keys: + handler.delete_file(key) + keys = [] + existing = InterviewHandoff.objects.filter( + source=source, idempotency_key=payload["idempotency_key"], fingerprint=digest + ).first() + if not existing: + raise HandoffError("Idempotency key or source_id is already in use.", status=409) from None + return _response(request, existing) + # Metadata outages must not undo a successful durable handoff. Resolution + # also runs when the filer opens the draft, after choosing a court. + return _response(request, receipt, created=True) + except HandoffError as exc: + for key in keys: + handler.delete_file(key) + return JsonResponse({"error": str(exc)}, status=exc.status) + + +def _private(response): + response["Cache-Control"] = "no-store" + response["Referrer-Policy"] = "same-origin" + return response + + +@require_http_methods(["GET", "POST"]) +def handoff_claim(request, token): + try: + value = signing.loads(token, salt=CLAIM_SALT, max_age=settings.LITEFILE_HANDOFF_TOKEN_MAX_AGE) + except signing.BadSignature: + return _private(render(request, "efile/handoff_claim.html", {"expired": True}, status=410)) + receipt = get_object_or_404(InterviewHandoff, pk=value["receipt"]) + draft = receipt.draft + if not request.user.is_authenticated or not get_tyler_token(request, draft.jurisdiction): + request.session["handoff_continue"] = request.path + 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 == "POST": + with transaction.atomic(): + draft = FilingDraft.objects.select_for_update().get(pk=draft.pk) + if draft.user_id not in (None, request.user.pk): + return _private(JsonResponse({"error": "This draft belongs to another account."}, status=403)) + if draft.user_id is None: + draft.user = request.user + draft.save(update_fields=["user", "updated_at"]) + resolve_metadata(draft) + attach_current_draft(request, draft) + return _private(redirect("handoff_review", draft_id=draft.pk)) + return _private( + render(request, "efile/handoff_claim.html", {"source": receipt.source, "jurisdiction": draft.jurisdiction}) + ) + + +def _owned(request, draft_id): + if not request.user.is_authenticated: + raise HandoffError("Sign in to open this draft.", status=401) + return get_object_or_404(FilingDraft, pk=draft_id, user=request.user) + + +def _issue_links(draft): + issues = issues_for(draft) + for issue in issues: + url = reverse(issue["view"], kwargs={"jurisdiction": draft.jurisdiction}) + issue["url"] = draft_url(url + "?return_to=handoff", draft.pk) + return issues + + +@require_http_methods(["GET", "POST"]) +def handoff_review(request, draft_id): + try: + draft = _owned(request, draft_id) + except HandoffError as exc: + return JsonResponse({"error": str(exc)}, status=exc.status) + if draft.status != FilingDraft.Status.DRAFT: + return _private(render(request, "efile/handoff_review.html", {"draft": draft, "closed": True})) + attach_current_draft(request, draft) + if request.method == "POST": + 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. + remaining = [] + for path in draft.correction_fields: + parts = path.split(".") + if len(parts) == 3: + row = getattr(draft, parts[0]).filter(pk=parts[1]).first() + value = getattr(row, parts[2], "") + else: + value = getattr(draft, path, "") if path != "documents" else "" + if not value: + remaining.append(path) + else: + record(draft, path, "user_confirmation", {"code": value}) + 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) + receipt = receipt_for(draft) + return _private( + render( + request, + "efile/handoff_review.html", + { + "draft": draft, + "issues": _issue_links(draft), + "receipt": receipt, + "documents": draft.documents.all(), + "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( + reverse("extraction_review", kwargs={"jurisdiction": draft.jurisdiction}) + "?return_to=handoff", + draft.pk, + ), + "documents_url": draft_url( + reverse("organize_documents", kwargs={"jurisdiction": draft.jurisdiction}) + "?return_to=handoff", + draft.pk, + ), + "parties_url": draft_url( + reverse("parties", kwargs={"jurisdiction": draft.jurisdiction}) + "?return_to=handoff", draft.pk + ), + }, + ) + ) + + +@require_http_methods(["GET", "POST"]) +def correct_filing(request, draft_id): + try: + draft = _owned(request, draft_id) + if not get_tyler_token(request, draft.jurisdiction): + return redirect("efile_login", jurisdiction=draft.jurisdiction) + filing_ids = submitted_filing_ids(draft.submission_response) + if not filing_ids: + raise HandoffError( + "This submission has no confirmed filing identifier. Check its status before retrying.", status=409 + ) + details = [] + for filing_id in sorted(filing_ids): + detail = describe_filing_detail( + fetch_filing_detail(request, draft.jurisdiction, draft.court_code, filing_id) + ) + if ( + not detail + or detail.get("filing_id") != filing_id + or detail.get("status", "").strip().lower() not in {"returned", "rejected"} + ): + raise HandoffError( + "The court has not confirmed a clerk return for every filing in this submission.", status=409 + ) + details.append(detail) + detail = { + **details[0], + "comments": [comment for item in details for comment in item.get("comments", [])], + "filings": details, + } + if request.method == "POST": + revision = create_correction(draft, detail, request.POST.getlist("fields")) + attach_current_draft(request, revision) + return redirect("handoff_review", draft_id=revision.pk) + choices = [ + (field, label) + for field, label in ( + ("court_code", "Court"), + ("case_category_code", "Case category"), + ("case_type_code", "Case type"), + ("documents", "PDF contents"), + ) + ] + choices.extend( + (f"documents.{doc.pk}.{field}", f"{doc.name}: {label}") + 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"), + ) + ) + choices.extend((f"parties.{party.pk}.party_type", f"{party}: party type") for party in draft.parties.all()) + return render(request, "efile/correct_filing.html", {"draft": draft, "detail": detail, "choices": choices}) + except HandoffError as exc: + return JsonResponse({"error": str(exc)}, status=exc.status) + + except (requests.RequestException, ValueError, TypeError): + return JsonResponse({"error": "The court status is unavailable. Try checking it again later."}, status=503) + + +@require_http_methods(["POST"]) +def return_to_interview(request, draft_id): + try: + draft = _owned(request, draft_id) + receipt = receipt_for(draft) + if draft.status != FilingDraft.Status.DRAFT or not receipt or not receipt.payload.get("return_url"): + raise HandoffError("This draft cannot return to its interview.", status=409) + token = signing.dumps({"draft": draft.pk, "source": receipt.source}, salt=REPLACE_SALT) + parts = urlsplit(str(receipt.payload["return_url"])) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["litefile_correction"] = token + return _private( + redirect(str(urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)))) + ) + except HandoffError as exc: + return JsonResponse({"error": str(exc)}, status=exc.status) + + +@csrf_exempt +@require_http_methods(["POST"]) +def replace_documents(request): + handler = S3UploadHandler() + keys = [] + try: + source, config = _source(request) + payload = _read(request, config) + token = request.headers.get("X-LITEFile-Correction", "") + try: + scope = signing.loads(token, salt=REPLACE_SALT, max_age=settings.LITEFILE_HANDOFF_TOKEN_MAX_AGE) + except signing.BadSignature: + raise HandoffError( + "The document correction link expired. Return from LITEFile again.", status=403 + ) from None + if scope.get("source") != source: + raise HandoffError("The correction belongs to another source.", status=403) + digest = fingerprint(payload) + with transaction.atomic(): + draft = get_object_or_404(FilingDraft.objects.select_for_update(), pk=scope["draft"]) + receipt = receipt_for(draft) + if ( + not receipt + or receipt.source != source + or receipt.source_id != payload["source_id"] + or draft.jurisdiction != payload["jurisdiction"] + ): + raise HandoffError("The replacement does not belong to this interview.", status=403) + prior = HandoffDocumentUpdate.objects.filter( + source=source, idempotency_key=payload["idempotency_key"] + ).first() + if prior: + if prior.draft_id != draft.pk or prior.fingerprint != digest: + raise HandoffError("The replacement idempotency key is already in use.", status=409) + else: + if draft.status != FilingDraft.Status.DRAFT: + raise HandoffError("Only an editable draft can receive replacement documents.", status=409) + by_source_id = {} + for event in draft.metadata_events.filter(kind="source_suggestion", path__startswith="documents."): + by_source_id[event.value["id"]] = event.path.split(".")[1] + if not payload.get("documents") or any(doc["id"] not in by_source_id for doc in payload["documents"]): + raise HandoffError("Replace documents using their original source document ids.") + updates = [] + for doc in payload["documents"]: + row = draft.documents.filter(pk=by_source_id[doc["id"]]).first() + if row is None: + raise HandoffError("A document was removed in LITEFile. Add its replacement there.", status=409) + updates.append((row, doc)) + HandoffDocumentUpdate.objects.create( + draft=draft, source=source, idempotency_key=payload["idempotency_key"], fingerprint=digest + ) + uploads = _upload(payload, request.FILES, handler, keys) + for row, doc in updates: + uploaded = uploads[doc["id"]] + row.s3_key = uploaded["key"] + row.public_url = uploaded["url"] + row.original_filename = uploaded["filename"] + row.size = uploaded["size"] + row.save(update_fields=["s3_key", "public_url", "original_filename", "size", "updated_at"]) + record( + draft, + f"documents.{row.pk}", + "document_replacement", + {"sha256": doc["sha256"], "source": source}, + ) + draft.correction_fields = [path for path in draft.correction_fields if path != "documents"] + draft.quoted_fee_total = "" + draft.quoted_fee_breakdown = [] + draft.selected_payment_account_id = "" + draft.save( + update_fields=[ + "correction_fields", + "quoted_fee_total", + "quoted_fee_breakdown", + "selected_payment_account_id", + "updated_at", + ] + ) + return JsonResponse( + { + "draft_id": str(draft.pk), + "state": "needs_input", + "issues": issues_for(draft), + "continue_url": request.build_absolute_uri(reverse("handoff_review", args=[draft.pk])), + } + ) + except HandoffError as exc: + for key in keys: + handler.delete_file(key) + return JsonResponse({"error": str(exc)}, status=exc.status) diff --git a/efile_app/efile/views/login.py b/efile_app/efile/views/login.py index ee15c8c5..65d2ec1d 100644 --- a/efile_app/efile/views/login.py +++ b/efile_app/efile/views/login.py @@ -35,12 +35,15 @@ def efile_login(request, jurisdiction): if user is not None: # response.status_code == 200: auth_tokens = request.session.get("auth_tokens", {}) + handoff_continue = request.session.get("handoff_continue") request.session.flush() login(request, user) request.session["auth_tokens"] = auth_tokens request.session["user_email"] = user.email request.session["jurisdiction"] = jurisdiction messages.success(request, "Successfully logged in!") + if handoff_continue and handoff_continue.startswith("/handoff/claim/"): + return redirect(handoff_continue) return redirect(f"/jurisdiction/{jurisdiction}/options/") else: messages.error(request, "Login service error. Please try again later.") diff --git a/efile_app/efile/views/my_cases.py b/efile_app/efile/views/my_cases.py index f1e03533..4083fda1 100644 --- a/efile_app/efile/views/my_cases.py +++ b/efile_app/efile/views/my_cases.py @@ -25,6 +25,7 @@ fetch_filing_detail, unarchive_case, ) +from efile.services.handoff import local_submission logger = logging.getLogger(__name__) @@ -118,6 +119,7 @@ def filing_detail(request, jurisdiction, court_code, filing_id): { "is_logged_in": True, "filing": detail, + "local_draft": local_submission(request.user, jurisdiction, court_code, filing_id), "court_code": court_code, "filing_id": filing_id, "court_contact": court_contact(jurisdiction, court_code), diff --git a/efile_app/efile/views/my_drafts.py b/efile_app/efile/views/my_drafts.py index 7759f197..10c2755c 100644 --- a/efile_app/efile/views/my_drafts.py +++ b/efile_app/efile/views/my_drafts.py @@ -22,6 +22,7 @@ pointed_at_draft, ) from efile.services.drafts import active_drafts_for +from efile.services.handoff import receipt_for from efile.workflow import ExistingCase, get_resume_step_url, get_step logger = logging.getLogger(__name__) @@ -63,6 +64,8 @@ def my_drafts(request, jurisdiction): messages.error(request, "That draft is no longer here.") elif action == "resume": adopt_draft(request, draft.pk, jurisdiction=jurisdiction) + if receipt_for(draft) or draft.correction_of_id: + return redirect("handoff_review", draft_id=draft.pk) resume_url = get_resume_step_url(draft.current_step, jurisdiction, draft_id=draft.pk) return redirect(resume_url or reverse("efile_options", kwargs={"jurisdiction": jurisdiction})) elif action == "delete": From 8237c3a6bcb85579b843811f91ec21b8b03f91b0 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 8 Sep 2026 19:31:48 -0400 Subject: [PATCH 2/3] Support court-specific filing hints --- .../issue-106-vermont-handoff-validation.md | 15 +++- .../partners-courts/interview-integration.md | 46 ++++++++++ efile_app/efile/services/handoff.py | 84 +++++++++++++++++-- efile_app/efile/tests/test_handoff.py | 68 ++++++++++++++- 4 files changed, 204 insertions(+), 9 deletions(-) diff --git a/docs/developer-notes/issue-106-vermont-handoff-validation.md b/docs/developer-notes/issue-106-vermont-handoff-validation.md index 60c7a149..75fbba44 100644 --- a/docs/developer-notes/issue-106-vermont-handoff-validation.md +++ b/docs/developer-notes/issue-106-vermont-handoff-validation.md @@ -100,8 +100,8 @@ minor-specific semantic hint was verified by a sender unit test. ## Final automated checks -- LITEFile: `uv run pytest -q` — 782 passed. -- Vermont sender: `python -m pytest -q tests/test_litefile.py` — 16 passed. +- LITEFile: `uv run pytest -q` — 784 passed. +- Vermont sender: `python -m pytest -q tests/test_litefile.py` — 17 passed. - LITEFile: `uv run ruff check .` and `uv run ty check` — passed. - Django: `makemigrations --check --dry-run` — no model drift; migration 0023 was applied to the isolated test database. @@ -179,7 +179,7 @@ Installed the refactored adapter on the running Docassemble server and verified: - Retrying the correction kept one replacement receipt and two total transfer caches (initial and correction). - Text extraction from the stored affidavit confirmed the new correction text. -- Sixteen adapter tests, Ruff, whitespace checks, and the documentation build +- Seventeen adapter tests, Ruff, whitespace checks, and the documentation build pass. The reusable person helper defaults to the standard AssemblyLine and @@ -191,6 +191,15 @@ The final live retry used the flat `litefile` server configuration and logged an empty argument mapping for `litefile_upload`. Credentials, correction tokens, and interview answers therefore remained outside the background job arguments. +Follow-up contract tests cover county and court-specific semantic hints. They +verify court-over-county-over-general precedence across case categories, case +types, filing types, document types, and filing components. Validation rejects +unknown document IDs and county keys that normalize to the same name. The +sender test confirms that the declarative override mapping reaches the payload. +The updated interview was also reinstalled and completed another live +background handoff: draft 11 received four PDFs plus the default empty county +and court override maps, and the success screen cleared the pending task. + The first attempt encountered a stopped LocalStack container and a recoverable HTTP 503. Restarting local storage allowed the same cached transfer to succeed. No additional court submission was made during this refactor validation. diff --git a/docs/docs/partners-courts/interview-integration.md b/docs/docs/partners-courts/interview-integration.md index d9132043..20137d22 100644 --- a/docs/docs/partners-courts/interview-integration.md +++ b/docs/docs/partners-courts/interview-integration.md @@ -121,6 +121,52 @@ suggestions remain in the receipt; numeric suggestions never populate resolved court-code fields. Supported questionnaire answers `has_children` and `child_count` also populate their normal filing fields. +### County-specific and court-specific hints + +Use `filing_hint_overrides` when a semantic name is correct in one filing +location and wrong in another. Matching county values replace general hints +field by field. Matching court values apply afterward and replace the county +value for the same field. Fields that an override omits continue using the less +specific value. + +```json +{ + "case_category_name_hints": ["Civil"], + "case_type_name_hints": ["General case type"], + "filing_hint_overrides": { + "counties": { + "Cook": { + "case_type_name_hints": ["Cook County case type"], + "documents": { + "complaint": { + "filing_type_name_hints": ["Cook County complaint"] + } + } + } + }, + "courts": { + "First Municipal District": { + "case_category_name_hints": ["Court-specific category"], + "documents": { + "complaint": { + "filing_type_name_hints": ["Court-specific complaint"], + "document_type_name_hints": ["Court-specific document type"], + "filing_component_name_hints": ["Court-specific lead document"] + } + } + } + } + } +} +``` + +County keys match `case.county`; they may include or omit the word `County`. +Court keys match either the source's `case.court_name` or the official court +name resolved from live metadata. Matching ignores capitalization and ordinary +name punctuation. Scoped `documents` keys must be document IDs declared in the +same request. Names remain semantic suggestions: LITEFile only saves a code +when exactly one live option matches. + ## Response, continuation, and retries A new receipt returns HTTP 201. An identical retry returns HTTP 200 with the same diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index 28d18ffe..cfa680aa 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -78,6 +78,71 @@ def _hints(value, path): _string(hint, field) +def _scope_name(value, scope): + name = normalize_name(value) + if scope == "counties": + name = name.removesuffix(" county") + return name + + +def _validate_filing_hint_overrides(payload, document_ids): + overrides = _object(payload.get("filing_hint_overrides", {}), "filing_hint_overrides") + for scope in ("counties", "courts"): + choices = _object(overrides.get(scope, {}), f"filing_hint_overrides.{scope}") + if len(choices) > 100: + raise HandoffError(f"filing_hint_overrides.{scope} supports up to 100 names.") + normalized = set() + for name, values in choices.items(): + _string(name, f"filing_hint_overrides.{scope} name") + key = _scope_name(name, scope) + if not key or key in normalized: + raise HandoffError(f"filing_hint_overrides.{scope} names must be unique.") + normalized.add(key) + values = _object(values, f"filing_hint_overrides.{scope}.{name}") + _hints(values, f"filing_hint_overrides.{scope}.{name}") + documents = _object(values.get("documents", {}), f"filing_hint_overrides.{scope}.{name}.documents") + if not set(documents).issubset(document_ids): + raise HandoffError("Scoped document hints must use a declared document id.") + for document_id, document_hints in documents.items(): + _hints( + _object( + document_hints, + f"filing_hint_overrides.{scope}.{name}.documents.{document_id}", + ), + f"filing_hint_overrides.{scope}.{name}.documents.{document_id}", + ) + + +def _matching_hint_override(payload, scope, candidates): + choices = payload.get("filing_hint_overrides", {}).get(scope, {}) + wanted = {_scope_name(candidate, scope) for candidate in candidates if candidate} + return next( + (values for name, values in choices.items() if _scope_name(name, scope) in wanted), + {}, + ) + + +def effective_hints(payload, *, court_name="", document=None): + """Apply general, county, then court-specific semantic hint overrides.""" + base = document if document is not None else payload + result = {field: list(value) for field in HINT_FIELDS if isinstance((value := base.get(field)), list)} + case = payload.get("case", {}) + scopes = ( + _matching_hint_override(payload, "counties", [case.get("county", "")]), + _matching_hint_override( + payload, + "courts", + [case.get("court_name", ""), court_name], + ), + ) + for override in scopes: + selected = override.get("documents", {}).get(document.get("id", ""), {}) if document else override + for field in HINT_FIELDS: + if field in selected: + result[field] = list(selected[field]) + return result + + def validate_payload(payload, source_config, files): _object(payload, "payload") if type(payload.get("schema_version")) is not int or payload["schema_version"] != 1: @@ -98,6 +163,7 @@ def validate_payload(payload, source_config, files): raise HandoffError("case.existing_case must be true or false when known.") for field in CASE_FIELDS: _string(case.get(field, ""), f"case.{field}", FilingDraft._meta.get_field(field).max_length) + _string(case.get("county", ""), "case.county") _object(payload.get("known_filing_facts", {}), "known_filing_facts") parties = payload.get("parties", []) if not isinstance(parties, list) or len(parties) > 100: @@ -167,6 +233,7 @@ def validate_payload(payload, source_config, files): raise HandoffError("A document bundle needs exactly one lead PDF.") if set(files) != ids or any(len(files.getlist(key)) != 1 for key in files): raise HandoffError("Upload each declared document exactly once.") + _validate_filing_hint_overrides(payload, ids) return payload @@ -296,12 +363,13 @@ def choose(obj, field, options, hints): choose(draft, "court_code", _codes(draft.jurisdiction, "", with_names=True), court_hints(draft, payload)) if not draft.court_code: return + payload_hints = effective_hints(payload, court_name=draft.court_name) timing = "Subsequent" if draft.existing_case == ExistingCase.EXISTING else "Initial" choose( draft, "case_category_code", _codes(draft.jurisdiction, f"{draft.court_code}/categories", timing=timing, fileable_only=True), - intent.get("case_category_name_aliases", []) + payload.get("case_category_name_hints", []), + intent.get("case_category_name_aliases", []) + payload_hints.get("case_category_name_hints", []), ) if not draft.case_category_code: return @@ -311,7 +379,7 @@ def choose(obj, field, options, hints): _codes( draft.jurisdiction, f"{draft.court_code}/case_types/", category_id=draft.case_category_code, timing=timing ), - intent.get("case_type_name_aliases", []) + payload.get("case_type_name_hints", []), + intent.get("case_type_name_aliases", []) + payload_hints.get("case_type_name_hints", []), ) if not draft.case_type_code: return @@ -325,6 +393,7 @@ def choose(obj, field, options, hints): for document in draft.documents.all(): suggestion = draft.metadata_events.filter(path=f"documents.{document.pk}", kind="source_suggestion").first() hints = suggestion.value if suggestion else {} + document_hints = effective_hints(payload, court_name=draft.court_name, document=hints) curated = intent.get("documents", {}).get(hints.get("id", ""), {}) choose( document, @@ -332,9 +401,9 @@ def choose(obj, field, options, hints): options, curated.get("filing_type_name_aliases", []) + ( - hints.get("filing_type_name_hints", []) + document_hints.get("filing_type_name_hints", []) or ( - intent.get("filing_type_name_aliases", []) + payload.get("filing_type_name_hints", []) + intent.get("filing_type_name_aliases", []) + payload_hints.get("filing_type_name_hints", []) if document.role == "lead" else [hints.get("form_name", "")] ) @@ -348,7 +417,12 @@ def choose(obj, field, options, hints): choices = _codes( draft.jurisdiction, f"{draft.court_code}/filing_types/{document.filing_type_code}/{endpoint}" ) - choose(document, field, choices, hints.get(field.replace("_code", "_name_hints"), [])) + choose( + document, + field, + choices, + document_hints.get(field.replace("_code", "_name_hints"), []), + ) party_options = _codes(draft.jurisdiction, f"{draft.court_code}/case_types/{draft.case_type_code}/party_types") for party in draft.parties.all(): if party.role == "filer" and not (party.is_self or party.is_filing_party): diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index e2703abc..53005939 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -7,7 +7,7 @@ from django.urls import reverse from efile.models import FilingDraft, InterviewHandoff -from efile.services.handoff import HandoffError, create_correction, resolve_metadata, unique_match +from efile.services.handoff import HandoffError, create_correction, effective_hints, resolve_metadata, unique_match pytestmark = pytest.mark.django_db PDF = b"%PDF-1.4\nsynthetic test document" @@ -159,6 +159,72 @@ def codes(jurisdiction, path, **params): assert InterviewHandoff.objects.get().payload["documents"][0]["filing_type_code"] == "9999" +def test_county_and_court_hint_overrides_replace_general_hints(client, source, payload, storage): + payload["case"]["county"] = "Cook County" + payload["filing_hint_overrides"] = { + "counties": { + "Cook": { + "case_category_name_hints": ["County category"], + "case_type_name_hints": ["County case type"], + "documents": {"complaint": {"document_type_name_hints": ["County confidential"]}}, + } + }, + "courts": { + "Test Family Court": { + "case_category_name_hints": ["Court category"], + "documents": { + "complaint": { + "filing_type_name_hints": ["Court complaint"], + "filing_component_name_hints": ["Court lead"], + } + }, + } + }, + } + assert effective_hints(payload, court_name="Test family court")["case_category_name_hints"] == ["Court category"] + send(client, source, payload) + draft = FilingDraft.objects.get() + + def codes(jurisdiction, path, **params): + return { + "": [{"code": "vt", "name": "Test family court"}], + "vt/categories": [ + {"code": "county", "name": "County category"}, + {"code": "court", "name": "Court category"}, + ], + "vt/case_types/": [{"code": "county-type", "name": "County case type"}], + "vt/filing_types/": [{"code": "court-filing", "name": "Court complaint"}], + "vt/filing_types/court-filing/document_types": [ + {"code": "county-confidential", "name": "County confidential"} + ], + "vt/filing_types/court-filing/filing_components": [{"code": "court-lead", "name": "Court lead"}], + }.get(path, []) + + with patch("efile.services.handoff._codes", side_effect=codes): + resolve_metadata(draft) + draft.refresh_from_db() + document = draft.documents.get() + assert draft.case_category_code == "court" + assert draft.case_type_code == "county-type" + assert document.filing_type_code == "court-filing" + assert document.document_type_code == "county-confidential" + assert document.filing_component_code == "court-lead" + + +def test_scoped_hints_reject_unknown_documents_and_duplicate_counties(client, source, payload, storage): + payload["filing_hint_overrides"] = { + "counties": {"Cook": {"documents": {"missing": {"filing_type_name_hints": ["Complaint"]}}}} + } + assert send(client, source, payload).status_code == 400 + payload["filing_hint_overrides"] = { + "counties": { + "Cook": {"case_type_name_hints": ["One"]}, + "Cook County": {"case_type_name_hints": ["Two"]}, + } + } + assert send(client, source, payload).status_code == 400 + + def test_correction_preserves_snapshot_and_blocks_ambiguous_attempts( client, source, payload, storage, django_user_model ): From 992e8dd057e9b16fcc518b09a788898a907d3855 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:47:26 +0000 Subject: [PATCH 3/3] Address PR review thread feedback Co-authored-by: nonprofittechy <7645641+nonprofittechy@users.noreply.github.com> --- efile_app/efile/services/handoff.py | 12 ++++- efile_app/efile/signals.py | 4 ++ .../efile/templates/efile/confirmation.html | 4 +- .../efile/templates/efile/correct_filing.html | 24 +++++---- efile_app/efile/tests/test_handoff.py | 50 +++++++++++++++++++ efile_app/efile/views/handoff.py | 10 +++- 6 files changed, 89 insertions(+), 15 deletions(-) diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py index cfa680aa..9c136301 100644 --- a/efile_app/efile/services/handoff.py +++ b/efile_app/efile/services/handoff.py @@ -613,11 +613,19 @@ def create_correction(draft, detail, fields): if any(field in fields for field in ("court_code", "case_category_code", "case_type_code")): if "court_code" in fields: revision.case_category_code = "" + revision.case_category_name = "" revision.case_type_code = "" + revision.case_type_name = "" revision.documents.update( - filing_type_code="", document_type_code="", filing_component_code="", requested_optional_services=[] + filing_type_code="", + filing_type_name="", + document_type_code="", + document_type_name="", + filing_component_code="", + filing_component_name="", + requested_optional_services=[], ) - revision.parties.update(party_type="") + revision.parties.update(party_type="", party_type_name="") revision.optional_services = [] revision.correction_fields = revised_fields revision.save() diff --git a/efile_app/efile/signals.py b/efile_app/efile/signals.py index c3a83148..009a141d 100644 --- a/efile_app/efile/signals.py +++ b/efile_app/efile/signals.py @@ -34,6 +34,10 @@ def remember_metadata_before_edit(sender, instance, **kwargs): if not instance.pk: instance._metadata_before = None return + update_fields = kwargs.get("update_fields") + if update_fields is not None and not set(update_fields).intersection(_METADATA_FIELDS[sender]): + instance._metadata_before = None + return instance._metadata_before = sender.objects.filter(pk=instance.pk).values(*_METADATA_FIELDS[sender]).first() diff --git a/efile_app/efile/templates/efile/confirmation.html b/efile_app/efile/templates/efile/confirmation.html index a2183971..e29d6086 100644 --- a/efile_app/efile/templates/efile/confirmation.html +++ b/efile_app/efile/templates/efile/confirmation.html @@ -8,7 +8,9 @@ {% endblock extra_css %} {% block workflow_content %} -

Check for a clerk return and correct this filing

+

+ {% translate "Check for a clerk return and correct this filing" %} +

diff --git a/efile_app/efile/templates/efile/correct_filing.html b/efile_app/efile/templates/efile/correct_filing.html index 49041382..1108a610 100644 --- a/efile_app/efile/templates/efile/correct_filing.html +++ b/efile_app/efile/templates/efile/correct_filing.html @@ -2,14 +2,18 @@ {% block title %}Correct and resubmit{% endblock title %} {% block public_content %}

Correct and resubmit

-

The court returned this filing. Select the details the clerk asked you to correct. Your other answers and documents will stay in the same matter.

- {% for comment in detail.comments %}

{{ comment.text }}

{% endfor %} -
- {% csrf_token %} -
- What needs a correction? - {% for value, label in choices %}
{% endfor %} -
- -
+ {% if error %} +

{{ error }}

+ {% else %} +

The court returned this filing. Select the details the clerk asked you to correct. Your other answers and documents will stay in the same matter.

+ {% for comment in detail.comments %}

{{ comment.text }}

{% endfor %} +
+ {% csrf_token %} +
+ What needs a correction? + {% for value, label in choices %}
{% endfor %} +
+ +
+ {% endif %} {% endblock public_content %} diff --git a/efile_app/efile/tests/test_handoff.py b/efile_app/efile/tests/test_handoff.py index 53005939..3ad5f241 100644 --- a/efile_app/efile/tests/test_handoff.py +++ b/efile_app/efile/tests/test_handoff.py @@ -234,7 +234,17 @@ def test_correction_preserves_snapshot_and_blocks_ambiguous_attempts( draft.save() doc = draft.documents.get() doc.filing_type_code = "old" + doc.filing_type_name = "Old filing type" + doc.document_type_name = "Old document type" + doc.filing_component_name = "Old filing component" doc.save() + party = draft.parties.get() + party.party_type = "PET" + party.party_type_name = "Petitioner" + party.save() + draft.case_category_name = "Family" + draft.case_type_name = "Relief from abuse" + draft.save() with pytest.raises(HandoffError): create_correction(draft, {"status": "rejected"}, [f"documents.{doc.pk}.filing_type_code"]) draft.mark_submitted({"filing_id": "confirmed"}) @@ -252,6 +262,32 @@ def test_correction_preserves_snapshot_and_blocks_ambiguous_attempts( assert revision.selected_payment_account_id == "" +def test_scoped_correction_clears_dependent_names(client, source, payload, storage, django_user_model): + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.case_category_name = "Family" + draft.case_type_name = "Relief from abuse" + draft.save() + document = draft.documents.get() + document.filing_type_name = "Old filing type" + document.document_type_name = "Old document type" + document.filing_component_name = "Old filing component" + document.save() + party = draft.parties.get() + party.party_type_name = "Petitioner" + party.save() + draft.mark_submitted({"filing_id": "confirmed"}) + scoped = create_correction(draft, {"status": "rejected"}, ["court_code"]) + scoped_document = scoped.documents.get() + assert scoped.case_category_name == "" + assert scoped.case_type_name == "" + assert scoped_document.filing_type_name == "" + assert scoped_document.document_type_name == "" + assert scoped_document.filing_component_name == "" + assert scoped.parties.get().party_type_name == "" + + def test_replacement_retains_metadata_and_is_idempotent(client, source, payload, storage, django_user_model): from urllib.parse import parse_qs, urlsplit @@ -374,9 +410,23 @@ def test_correction_api_rejects_partly_accepted_submission(client, django_user_m ): response = client.post(reverse("correct_filing", args=[draft.pk]), {"fields": ["documents"]}) assert response.status_code == 409 + assert b"The court has not confirmed a clerk return for every filing in this submission." in response.content + assert b"What needs a correction?" not in response.content assert not FilingDraft.objects.filter(correction_of=draft).exists() +def test_non_metadata_save_does_not_fetch_previous_metadata(client, source, payload, storage, django_user_model): + send(client, source, payload) + draft = FilingDraft.objects.get() + draft.user = login(client, django_user_model) + draft.save() + document = draft.documents.get() + with patch("efile.signals.FilingDocument.objects.filter") as existing: + document.name = "Renamed document" + document.save(update_fields=["name"]) + existing.assert_not_called() + + def test_correction_attempts_share_the_remote_case_group(django_user_model): from efile.services.handoff import matter_keys diff --git a/efile_app/efile/views/handoff.py b/efile_app/efile/views/handoff.py index 81ea4c4e..a15fb4c0 100644 --- a/efile_app/efile/views/handoff.py +++ b/efile_app/efile/views/handoff.py @@ -246,6 +246,7 @@ def handoff_review(request, draft_id): @require_http_methods(["GET", "POST"]) def correct_filing(request, draft_id): + draft = None try: draft = _owned(request, draft_id) if not get_tyler_token(request, draft.jurisdiction): @@ -299,10 +300,15 @@ def correct_filing(request, draft_id): choices.extend((f"parties.{party.pk}.party_type", f"{party}: party type") for party in draft.parties.all()) return render(request, "efile/correct_filing.html", {"draft": draft, "detail": detail, "choices": choices}) except HandoffError as exc: - return JsonResponse({"error": str(exc)}, status=exc.status) + return render(request, "efile/correct_filing.html", {"draft": draft, "error": str(exc)}, status=exc.status) except (requests.RequestException, ValueError, TypeError): - return JsonResponse({"error": "The court status is unavailable. Try checking it again later."}, status=503) + return render( + request, + "efile/correct_filing.html", + {"draft": draft, "error": "The court status is unavailable. Try checking it again later."}, + status=503, + ) @require_http_methods(["POST"])