diff --git a/docs/screenshots/reorganized-flow/01-filing-path.png b/docs/screenshots/reorganized-flow/01-filing-path.png new file mode 100644 index 0000000..46a0595 Binary files /dev/null and b/docs/screenshots/reorganized-flow/01-filing-path.png differ diff --git a/docs/screenshots/reorganized-flow/02-upload-documents.png b/docs/screenshots/reorganized-flow/02-upload-documents.png new file mode 100644 index 0000000..334b032 Binary files /dev/null and b/docs/screenshots/reorganized-flow/02-upload-documents.png differ diff --git a/docs/screenshots/reorganized-flow/03-extraction-review.png b/docs/screenshots/reorganized-flow/03-extraction-review.png new file mode 100644 index 0000000..e097af2 Binary files /dev/null and b/docs/screenshots/reorganized-flow/03-extraction-review.png differ diff --git a/efile_app/efile/services/current_drafts.py b/efile_app/efile/services/current_drafts.py index 2e9d756..a42120a 100644 --- a/efile_app/efile/services/current_drafts.py +++ b/efile_app/efile/services/current_drafts.py @@ -83,11 +83,13 @@ def create_current_draft( jurisdiction: str, *, current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, + workflow_version: int = 1, ) -> FilingDraft: draft = create_draft( user=_authenticated_user(request), jurisdiction=jurisdiction, current_step=current_step, + workflow_version=workflow_version, ) attach_current_draft(request, draft) return draft @@ -99,6 +101,7 @@ def ensure_current_draft( jurisdiction: str, *, current_step: WorkflowStepKey | str | None = None, + workflow_version: int | None = None, ) -> FilingDraft: draft = get_current_draft(request, jurisdiction=jurisdiction) if draft is None: @@ -106,7 +109,11 @@ def ensure_current_draft( request, jurisdiction, current_step=current_step or WorkflowStepKey.OPTIONS, + workflow_version=workflow_version or 1, ) if current_step is not None: set_current_step(draft, current_step) + if workflow_version is not None and draft.workflow_version != workflow_version: + draft.workflow_version = workflow_version + draft.save(update_fields=["workflow_version", "updated_at"]) return draft diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 00c9a50..6a66100 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -54,6 +54,7 @@ def create_draft( user, jurisdiction: str, current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, + workflow_version: int = 1, ) -> FilingDraft: """Create a durable draft owned by an authenticated user.""" @@ -66,6 +67,7 @@ def create_draft( user=user, jurisdiction=jurisdiction, current_step=str(current_step), + workflow_version=workflow_version, ) diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css new file mode 100644 index 0000000..beafb19 --- /dev/null +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -0,0 +1,434 @@ +.workflow-shell { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 4rem; +} + +.workflow-progress { + margin: 0 0 2rem; + overflow-x: auto; + padding: 0.25rem 0 0.75rem; +} + +.workflow-progress ol { + align-items: flex-start; + display: flex; + list-style: none; + margin: 0; + min-width: 850px; + padding: 0; +} + +.workflow-progress__item { + color: #687386; + flex: 1; + font-size: 0.74rem; + font-weight: 650; + position: relative; + text-align: center; +} + +.workflow-progress__item::before { + background: #d8dee8; + content: ""; + height: 2px; + left: -50%; + position: absolute; + right: 50%; + top: 15px; +} + +.workflow-progress__item:first-child::before { + display: none; +} + +.workflow-progress__marker { + align-items: center; + background: #fff; + border: 2px solid #bcc5d2; + border-radius: 50%; + display: inline-flex; + height: 32px; + justify-content: center; + margin-bottom: 0.4rem; + position: relative; + width: 32px; + z-index: 1; +} + +.workflow-progress__label { + display: block; +} + +.workflow-progress__item--complete::before, +.workflow-progress__item--current::before { + background: #2c5aa0; +} + +.workflow-progress__item--complete .workflow-progress__marker { + background: #2c5aa0; + border-color: #2c5aa0; + color: #fff; +} + +.workflow-progress__item--current { + color: #163f75; +} + +.workflow-progress__item--current .workflow-progress__marker { + border-color: #2c5aa0; + box-shadow: 0 0 0 4px rgb(44 90 160 / 14%); + color: #163f75; +} + +.workflow-card { + background: #fff; + border: 1px solid #dce2ea; + border-radius: 16px; + box-shadow: 0 12px 36px rgb(20 46 82 / 8%); + margin: 0 auto; + max-width: 900px; + padding: clamp(1.5rem, 4vw, 3rem); +} + +.workflow-card--narrow { + max-width: 760px; +} + +.workflow-eyebrow { + color: #2c5aa0; + font-size: 0.82rem; + font-weight: 750; + letter-spacing: 0.08em; + margin-bottom: 0.55rem; + text-transform: uppercase; +} + +.workflow-card h1 { + color: #172b46; + font-size: clamp(1.8rem, 4vw, 2.5rem); + letter-spacing: -0.025em; + margin-bottom: 0.8rem; +} + +.workflow-lede { + color: #536176; + font-size: 1.05rem; + line-height: 1.6; + margin-bottom: 2rem; + max-width: 720px; +} + +.choice-stack { + display: grid; + gap: 0.85rem; +} + +.choice-card { + align-items: center; + border: 2px solid #d9e0e9; + border-radius: 12px; + cursor: pointer; + display: grid; + gap: 1rem; + grid-template-columns: auto auto 1fr; + padding: 1.1rem 1.2rem; + transition: 140ms ease; +} + +.choice-card:has(input:checked) { + background: #f0f6ff; + border-color: #2c5aa0; + box-shadow: 0 0 0 3px rgb(44 90 160 / 10%); +} + +.choice-card input { + height: 1.2rem; + width: 1.2rem; +} + +.choice-card__icon { + align-items: center; + background: #eaf1fb; + border-radius: 10px; + color: #2c5aa0; + display: flex; + font-size: 1.15rem; + height: 44px; + justify-content: center; + width: 44px; +} + +.choice-card strong, +.choice-card small { + display: block; +} + +.choice-card strong { + color: #1d2f49; + margin-bottom: 0.2rem; +} + +.choice-card small { + color: #647187; +} + +.workflow-actions { + align-items: center; + border-top: 1px solid #e3e7ed; + display: flex; + justify-content: space-between; + margin-top: 2rem; + padding-top: 1.5rem; +} + +.workflow-actions .btn { + border-radius: 8px; + font-weight: 650; + padding: 0.7rem 1.15rem; +} + +.requirements-strip { + background: #f5f7fa; + border-radius: 10px; + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 1.3rem; + padding: 0.85rem 1rem; +} + +.requirements-strip span { + color: #48576c; + font-size: 0.9rem; +} + +.requirements-strip i { + color: #2c5aa0; + margin-right: 0.35rem; +} + +.drop-zone { + align-items: center; + background: #fbfcfe; + border: 2px dashed #9bacc2; + border-radius: 14px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.35rem; + justify-content: center; + min-height: 190px; + padding: 2rem; + text-align: center; +} + +.drop-zone--active, +.drop-zone:hover { + background: #f0f6ff; + border-color: #2c5aa0; +} + +.drop-zone input { + height: 1px; + opacity: 0; + position: absolute; + width: 1px; +} + +.drop-zone__icon { + color: #2c5aa0; + font-size: 2.2rem; +} + +.drop-zone span:last-child { + color: #687386; + font-size: 0.9rem; +} + +.upload-state { + align-items: center; + background: #eef5ff; + border-radius: 10px; + color: #234d84; + display: grid; + gap: 0.15rem 0.8rem; + grid-template-columns: auto 1fr; + margin-top: 1rem; + padding: 1rem; +} + +.upload-state span { + color: #526a88; + font-size: 0.88rem; + grid-column: 2; +} + +.document-list { + margin-top: 2rem; +} + +.document-list__heading { + align-items: center; + display: flex; + gap: 0.6rem; + margin-bottom: 0.8rem; +} + +.document-list h2 { + color: #213957; + font-size: 1.15rem; + font-weight: 700; + margin: 0; +} + +.document-row { + align-items: center; + border: 1px solid #dfe5ed; + border-radius: 10px; + display: grid; + gap: 0.8rem; + grid-template-columns: auto 1fr auto auto; + margin-top: 0.65rem; + padding: 0.85rem 1rem; +} + +.document-row__icon { + color: #c53434; + font-size: 1.4rem; +} + +.document-row__details strong, +.document-row__details small { + display: block; +} + +.document-row__details small { + color: #687386; +} + +.status-pill { + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + padding: 0.3rem 0.6rem; +} + +.status-pill--ready { + background: #e5f6eb; + color: #16703a; +} + +.document-list__empty { + border: 1px solid #e1e6ed; + border-radius: 10px; + color: #788497; + padding: 1.5rem; + text-align: center; +} + +.document-list__empty i { + font-size: 1.6rem; +} + +.document-list__empty p { + margin: 0.4rem 0 0; +} + +.review-grid { + display: grid; + gap: 1.1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-field>span { + color: #263c58; + display: block; + font-weight: 700; + margin-bottom: 0.35rem; +} + +.form-field em { + color: #778397; + font-size: 0.8rem; + font-style: normal; + font-weight: 500; +} + +.form-field small { + color: #758196; + display: block; + margin-top: 0.3rem; +} + +.path-confirmation { + background: #f7f9fc; + border: 1px solid #dfe5ed; + border-radius: 12px; + display: grid; + gap: 0.7rem; + margin-top: 2rem; + padding: 1.2rem; +} + +.path-confirmation legend { + color: #243a56; + float: none; + font-size: 1rem; + font-weight: 750; + margin-bottom: 0.4rem; + width: auto; +} + +.path-confirmation>label { + align-items: flex-start; + background: #fff; + border: 1px solid #d8e0e9; + border-radius: 8px; + display: flex; + gap: 0.75rem; + padding: 0.8rem; +} + +.path-confirmation small { + color: #6d798d; + display: block; +} + +.help-toggle { + background: none; + border: 0; + color: #2c5aa0; + font-weight: 650; + justify-self: start; + padding: 0.35rem 0; +} + +.help-panel { + background: #fff8df; + border-left: 4px solid #e0ac2e; + padding: 1rem; +} + +.help-panel p { + margin: 0.35rem 0 0; +} + +@media (max-width: 700px) { + .review-grid { + grid-template-columns: 1fr; + } + + .workflow-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .document-row { + grid-template-columns: auto 1fr auto; + } + + .status-pill { + grid-column: 2; + } +} \ No newline at end of file diff --git a/efile_app/efile/static/js/upload-documents.js b/efile_app/efile/static/js/upload-documents.js new file mode 100644 index 0000000..f4f3d92 --- /dev/null +++ b/efile_app/efile/static/js/upload-documents.js @@ -0,0 +1,97 @@ +(function() { + const form = document.getElementById("document-upload-form"); + if (!form) return; + + const input = document.getElementById("documents-input"); + const dropZone = document.getElementById("document-drop-zone"); + const uploadButton = document.getElementById("upload-button"); + const state = document.getElementById("upload-state"); + const stateTitle = document.getElementById("upload-state-title"); + const stateDetail = document.getElementById("upload-state-detail"); + const errorBox = document.getElementById("upload-error"); + + function setFiles(files) { + if (!files.length) return; + const transfer = new DataTransfer(); + Array.from(files).forEach((file) => transfer.items.add(file)); + input.files = transfer.files; + uploadButton.disabled = false; + dropZone.querySelector("strong").textContent = `${files.length} file${files.length === 1 ? "" : "s"} selected`; + } + + input.addEventListener("change", () => setFiles(input.files)); + ["dragenter", "dragover"].forEach((eventName) => { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.classList.add("drop-zone--active"); + }); + }); + ["dragleave", "drop"].forEach((eventName) => { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.classList.remove("drop-zone--active"); + }); + }); + dropZone.addEventListener("drop", (event) => setFiles(event.dataTransfer.files)); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + errorBox.hidden = true; + state.hidden = false; + uploadButton.disabled = true; + stateTitle.textContent = "Uploading your documents…"; + stateDetail.textContent = "Keep this page open."; + + const analyzingTimer = window.setTimeout(() => { + stateTitle.textContent = "Analyzing your first PDF…"; + stateDetail.textContent = "We're looking for the court, case type, and case number."; + }, 900); + + try { + const response = await fetch(window.location.href, { + method: "POST", + body: new FormData(form), + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + }, + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || "Upload failed."); + stateTitle.textContent = "Your documents are ready"; + stateDetail.textContent = "Review what we found before continuing."; + window.setTimeout(() => window.location.reload(), 500); + } catch (error) { + state.hidden = true; + errorBox.textContent = error.message; + errorBox.hidden = false; + uploadButton.disabled = false; + } finally { + window.clearTimeout(analyzingTimer); + } + }); + + document.querySelectorAll(".remove-document").forEach((button) => { + button.addEventListener("click", async () => { + if (!window.confirm("Remove this document from your filing?")) return; + const body = new FormData(); + body.append("action", "remove"); + body.append("document_id", button.dataset.documentId); + body.append("csrfmiddlewaretoken", apiUtils.getCSRFToken()); + const response = await fetch(window.location.href, { + method: "POST", + body + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + const result = await response.json(); + if (response.ok && result.success) window.location.reload(); + else window.alert(result.error || "Could not remove the document."); + }); + }); +})(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/components/workflow_progress.html b/efile_app/efile/templates/efile/components/workflow_progress.html new file mode 100644 index 0000000..4bf78e7 --- /dev/null +++ b/efile_app/efile/templates/efile/components/workflow_progress.html @@ -0,0 +1,17 @@ + diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html new file mode 100644 index 0000000..6e6ebb5 --- /dev/null +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -0,0 +1,86 @@ +{% extends "efile/workflow_base.html" %} +{% load i18n %} +{% block title %} + {% translate "Review what we found" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "Confirm case" %}
+

{% translate "Here's what we found" %}

+

+ {% translate "We read your lead document to get a head start. Check these details and correct anything that is wrong." %} +

+
+ {% csrf_token %} +
+ + + + +
+
+ {% translate "Is this for a new or existing court case?" %} + + + +
+ {% translate "Look near the top of your document." %} +

+ {% translate "A case number usually means the court has already opened the case. If your document starts a lawsuit or petition and has no case number, choose New case." %} +

+
+
+
+ {% translate "Back" %} + +
+
+
+{% endblock workflow_content %} diff --git a/efile_app/efile/templates/efile/filing_path.html b/efile_app/efile/templates/efile/filing_path.html new file mode 100644 index 0000000..14d248e --- /dev/null +++ b/efile_app/efile/templates/efile/filing_path.html @@ -0,0 +1,52 @@ +{% extends "efile/workflow_base.html" %} +{% load i18n %} +{% block title %} + {% translate "Choose your filing path" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "Filing" %}
+

{% translate "What are you trying to do?" %}

+

+ {% translate "Choose the option that best describes your filing. You can change this after we review your documents." %} +

+
+ {% csrf_token %} +
+ {% translate "Filing path" %} + + + +
+
+ {% translate "Back" %} + +
+
+
+{% endblock workflow_content %} diff --git a/efile_app/efile/templates/efile/options.html b/efile_app/efile/templates/efile/options.html index 3fd1857..8d6ea74 100644 --- a/efile_app/efile/templates/efile/options.html +++ b/efile_app/efile/templates/efile/options.html @@ -99,7 +99,7 @@

{% translate "View past filings" %}

function goToExpertForm(new_or_existing) { if (new_or_existing === "new") { makeNewDraft().then((result) => { - window.location.href = result.redirect_url || `/jurisdiction/{{jurisdiction}}/upload_first/`; + window.location.href = result.redirect_url || `/jurisdiction/{{jurisdiction}}/filing-path/`; }); } else { const resumeUrl = JSON.parse(document.getElementById("resume-url").textContent); diff --git a/efile_app/efile/templates/efile/upload_documents.html b/efile_app/efile/templates/efile/upload_documents.html new file mode 100644 index 0000000..2288e19 --- /dev/null +++ b/efile_app/efile/templates/efile/upload_documents.html @@ -0,0 +1,88 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Upload your documents" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "Upload" %}
+

{% translate "Upload your court documents" %}

+

+ {% translate "Add the forms and other documents you want to file. We will analyze the first PDF, and you can choose the main document later." %} +

+
+ {% translate "PDF only" %} + {% translate "10 MB per file" %} + {% translate "Text must be readable" %} +
+
+ {% csrf_token %} + + + + +
+
+
+

{% translate "Your documents" %}

+ {{ documents|length }} +
+ {% if documents %} + {% for document in documents %} +
+ + + {{ document.name|default:document.original_filename }} + + {% if document.role == "lead" %} + {% translate "Analyzed first" %} + {% else %} + {% translate "Supporting document" %} + {% endif %} + + + {% translate "Ready" %} + +
+ {% endfor %} + {% else %} +
+ +

{% translate "No documents uploaded yet." %}

+
+ {% endif %} +
+
+ {% translate "Back" %} + {% translate "Review what we found" %} +
+
+{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/workflow_base.html b/efile_app/efile/templates/efile/workflow_base.html new file mode 100644 index 0000000..474ead0 --- /dev/null +++ b/efile_app/efile/templates/efile/workflow_base.html @@ -0,0 +1,43 @@ +{% load static %} +{% load i18n %} + + + + + + + {% block title %} + {% translate "Make a court filing" %} + {% endblock title %} + + + + + + {% block extra_css %} + {% endblock extra_css %} + + + {% include "efile/components/profile_header.html" %} +
+ {% include "efile/components/workflow_progress.html" %} + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% block workflow_content %} + {% endblock workflow_content %} +
+ {% include "efile/components/footer.html" %} + + + + {% block extra_js %} + {% endblock extra_js %} + + diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index 2c70b26..8da3135 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -297,11 +297,12 @@ def test_create_draft_view_creates_durable_draft(client, django_user_model): assert response.status_code == 200 payload = response.json() assert payload["success"] is True - assert payload["redirect_url"] == reverse("upload_first", kwargs={"jurisdiction": "illinois"}) + assert payload["redirect_url"] == reverse("filing_path", kwargs={"jurisdiction": "illinois"}) draft = FilingDraft.objects.get(user=user) assert draft.jurisdiction == "illinois" - assert draft.current_step == WorkflowStepKey.UPLOAD_FIRST + assert draft.current_step == WorkflowStepKey.FILING_PATH + assert draft.workflow_version == 2 assert payload["data"]["filing_draft"]["id"] == draft.pk diff --git a/efile_app/efile/tests/test_reorganized_start.py b/efile_app/efile/tests/test_reorganized_start.py new file mode 100644 index 0000000..6d6180b --- /dev/null +++ b/efile_app/efile/tests/test_reorganized_start.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.workflow import ExistingCase, WorkflowStepKey + + +def authorize(client, draft): + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session["jurisdiction"] = "illinois" + session.save() + + +@pytest.fixture +def reorganized_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="flow-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois", workflow_version=2) + client.force_login(user) + authorize(client, draft) + return draft + + +@pytest.mark.django_db +def test_filing_path_saves_normalized_branch(client, reorganized_draft): + response = client.post( + reverse("filing_path", kwargs={"jurisdiction": "illinois"}), + {"existing_case": ExistingCase.EXISTING}, + ) + + reorganized_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("upload_documents", kwargs={"jurisdiction": "illinois"}) + assert reorganized_draft.existing_case == ExistingCase.EXISTING + assert reorganized_draft.current_step == WorkflowStepKey.UPLOAD_DOCUMENTS + + +@pytest.mark.django_db +def test_upload_documents_persists_lead_supporting_and_guesses(client, reorganized_draft): + handler = MagicMock() + handler._ensure_initialized.return_value = True + handler.validate_file.return_value = {"valid": True} + handler.upload_file.side_effect = [ + {"success": True, "key": "lead.pdf"}, + {"success": True, "key": "supporting.pdf"}, + ] + handler.get_public_url.side_effect = ["https://example.com/lead.pdf", "https://example.com/supporting.pdf"] + lead = SimpleUploadedFile("petition.pdf", b"%PDF lead", content_type="application/pdf") + supporting = SimpleUploadedFile("exhibit.pdf", b"%PDF exhibit", content_type="application/pdf") + + with ( + patch("efile.views.upload_documents.S3UploadHandler", return_value=handler), + patch( + "efile.views.upload_documents._analyze_lead", + return_value={"court name": "Cook County", "case type": "Name Change"}, + ), + ): + response = client.post( + reverse("upload_documents", kwargs={"jurisdiction": "illinois"}), + {"documents": [lead, supporting]}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + reorganized_draft.refresh_from_db() + assert reorganized_draft.extracted_guesses["court"] == "Cook County" + assert reorganized_draft.documents.get(role=FilingDocument.Role.LEAD).name == "petition.pdf" + assert reorganized_draft.documents.get(role=FilingDocument.Role.SUPPORTING).name == "exhibit.pdf" + + +@pytest.mark.django_db +def test_removing_analyzed_document_cleans_storage_and_stale_guesses(client, reorganized_draft): + lead = FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="petition.pdf", + s3_key="lead/petition.pdf", + ) + supporting = FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.SUPPORTING, + sort_order=0, + name="exhibit.pdf", + ) + reorganized_draft.extracted_guesses = {"court": "Cook County"} + reorganized_draft.save(update_fields=["extracted_guesses", "updated_at"]) + handler = MagicMock() + handler._ensure_initialized.return_value = True + handler.delete_file.return_value = {"success": True} + + with patch("efile.views.upload_documents.S3UploadHandler", return_value=handler): + response = client.post( + reverse("upload_documents", kwargs={"jurisdiction": "illinois"}), + {"action": "remove", "document_id": lead.pk}, + ) + + reorganized_draft.refresh_from_db() + supporting.refresh_from_db() + assert response.status_code == 200 + assert response.json()["success"] is True + handler.delete_file.assert_called_once_with("lead/petition.pdf") + assert reorganized_draft.extracted_guesses == {} + assert supporting.role == FilingDocument.Role.LEAD + + +@pytest.mark.django_db +def test_extraction_review_branches_new_case_to_checklist(client, reorganized_draft): + FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + ) + + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "existing_case": ExistingCase.NEW, + "court_name": "Cook County Circuit Court", + "case_category_name": "Miscellaneous Remedy", + "case_type_name": "Name Change", + }, + ) + + reorganized_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + assert reorganized_draft.existing_case == ExistingCase.NEW + assert reorganized_draft.case_type_name == "Name Change" + assert reorganized_draft.current_step == WorkflowStepKey.DOCUMENT_CHECKLIST + + +@pytest.mark.django_db +def test_extraction_review_requires_a_case_path(client, reorganized_draft): + FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + ) + + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + {"existing_case": ExistingCase.UNSURE}, + ) + + assert response.status_code == 200 + assert b"Choose whether this is a new or existing court case" in response.content + + +@pytest.mark.django_db +def test_unmigrated_downstream_screen_bridges_to_legacy_flow(client, reorganized_draft): + response = client.get(reverse("document_checklist", kwargs={"jurisdiction": "illinois"})) + + reorganized_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("expert_form", kwargs={"jurisdiction": "illinois"}) + assert reorganized_draft.workflow_version == 1 diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 8fe82ed..08180f2 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -7,7 +7,10 @@ from .views.confirmation import filing_confirmation from .views.draft_views import create_draft_view, get_current_draft_view from .views.expert_form import efile_expert_form +from .views.extraction_review import extraction_review +from .views.filing_path import filing_path from .views.filing_statuses import filing_statuses +from .views.legacy_workflow import legacy_workflow_redirect from .views.login import efile_login, efile_logout, efile_password_reset from .views.options import efile_options from .views.payment import efile_payment @@ -25,6 +28,7 @@ ) from .views.submission import submit_final_filing from .views.upload import efile_upload +from .views.upload_documents import upload_documents from .views.upload_first import efile_upload_first @@ -46,6 +50,57 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//register/", efile_register, name="efile_register"), path("jurisdiction//password_reset/", efile_password_reset, name="efile_password_reset"), path("jurisdiction//options/", efile_options, name="efile_options"), + path("jurisdiction//filing-path/", filing_path, name="filing_path"), + path("jurisdiction//upload-documents/", upload_documents, name="upload_documents"), + path("jurisdiction//extraction-review/", extraction_review, name="extraction_review"), + path( + "jurisdiction//case-lookup/", + legacy_workflow_redirect, + {"destination": "case_lookup"}, + name="case_lookup", + ), + path( + "jurisdiction//case-confirmation/", + legacy_workflow_redirect, + {"destination": "case_confirmation"}, + name="case_confirmation", + ), + path( + "jurisdiction//document-checklist/", + legacy_workflow_redirect, + {"destination": "document_checklist"}, + name="document_checklist", + ), + path( + "jurisdiction//organize-documents/", + legacy_workflow_redirect, + {"destination": "organize_documents"}, + name="organize_documents", + ), + path( + "jurisdiction//your-information/", + legacy_workflow_redirect, + {"destination": "your_information"}, + name="your_information", + ), + path( + "jurisdiction//parties/", + legacy_workflow_redirect, + {"destination": "parties"}, + name="parties", + ), + path( + "jurisdiction//party-details/", + legacy_workflow_redirect, + {"destination": "party_details"}, + name="party_details", + ), + path( + "jurisdiction//case-questions/", + legacy_workflow_redirect, + {"destination": "case_questions"}, + name="case_questions", + ), path("jurisdiction//drafts/", create_draft_view, name="create_draft"), path("jurisdiction//filing_statuses/", filing_statuses, name="filing_statuses"), path("jurisdiction//expert_form/", efile_expert_form, name="expert_form"), diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index e2f0068..4bd4cbb 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -30,14 +30,19 @@ def create_draft_view(request, jurisdiction): return JsonResponse({"success": False, "error": "JSON body must be an object"}, status=400) flush_cache_stay_logged_in(request.session) - draft = create_current_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) + draft = create_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.FILING_PATH, + workflow_version=2, + ) logger.info("Created durable draft id=%s jurisdiction=%s", draft.pk, jurisdiction) return JsonResponse( { "success": True, "data": {"filing_draft": draft_snapshot(draft)}, - "redirect_url": get_step_url(WorkflowStepKey.UPLOAD_FIRST, jurisdiction), + "redirect_url": get_step_url(WorkflowStepKey.FILING_PATH, jurisdiction), } ) diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py new file mode 100644 index 0000000..5eb227e --- /dev/null +++ b/efile_app/efile/views/extraction_review.py @@ -0,0 +1,65 @@ +from django.contrib import messages +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDocument +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot, write_case_data +from efile.workflow import ( + ExistingCase, + WorkflowStepKey, + get_next_step, + get_step_url, + get_workflow_context, +) + + +@require_http_methods(["GET", "POST"]) +def extraction_review(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.EXTRACTION_REVIEW, + workflow_version=2, + ) + if not FilingDocument.objects.filter(draft=draft).exists(): + messages.error(request, "Upload at least one document before reviewing the filing.") + return redirect("upload_documents", jurisdiction=jurisdiction) + + if request.method == "POST": + existing_case = request.POST.get("existing_case", draft.existing_case) + if existing_case not in {ExistingCase.NEW, ExistingCase.EXISTING}: + messages.error(request, "Choose whether this is a new or existing court case to continue.") + else: + write_case_data( + draft, + { + "existing_case": existing_case, + "court_name": request.POST.get("court_name", ""), + "case_category_name": request.POST.get("case_category_name", ""), + "case_type_name": request.POST.get("case_type_name", ""), + "docket_number": request.POST.get("docket_number", ""), + }, + current_step=WorkflowStepKey.EXTRACTION_REVIEW, + ) + next_step = get_next_step(WorkflowStepKey.EXTRACTION_REVIEW, draft) + if next_step: + write_case_data(draft, {}, current_step=next_step.key) + return redirect(get_step_url(next_step.key, jurisdiction)) + + guesses = draft.extracted_guesses or {} + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "guesses": guesses, + "court_name": draft.court_name or guesses.get("court"), + "case_category_name": draft.case_category_name or guesses.get("case category"), + "case_type_name": draft.case_type_name or guesses.get("case type"), + "docket_number": draft.docket_number or guesses.get("docket number"), + } + context.update(get_workflow_context(WorkflowStepKey.EXTRACTION_REVIEW, jurisdiction, draft)) + return render(request, "efile/extraction_review.html", context) diff --git a/efile_app/efile/views/filing_path.py b/efile_app/efile/views/filing_path.py new file mode 100644 index 0000000..c181163 --- /dev/null +++ b/efile_app/efile/views/filing_path.py @@ -0,0 +1,42 @@ +from django.contrib import messages +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot, write_case_data +from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context + + +@require_http_methods(["GET", "POST"]) +def filing_path(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.FILING_PATH, + workflow_version=2, + ) + + if request.method == "POST": + existing_case = request.POST.get("existing_case", "") + allowed = {choice.value for choice in ExistingCase} + if existing_case not in allowed: + messages.error(request, "Choose the option that best describes your filing.") + else: + write_case_data( + draft, + {"existing_case": existing_case}, + current_step=WorkflowStepKey.UPLOAD_DOCUMENTS, + ) + return redirect(get_step_url(WorkflowStepKey.UPLOAD_DOCUMENTS, jurisdiction)) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "selected_path": draft.existing_case, + } + context.update(get_workflow_context(WorkflowStepKey.FILING_PATH, jurisdiction, draft)) + return render(request, "efile/filing_path.html", context) diff --git a/efile_app/efile/views/legacy_workflow.py b/efile_app/efile/views/legacy_workflow.py new file mode 100644 index 0000000..166e045 --- /dev/null +++ b/efile_app/efile/views/legacy_workflow.py @@ -0,0 +1,24 @@ +from django.shortcuts import redirect + +from efile.services.current_drafts import get_current_draft + + +def legacy_workflow_redirect(request, jurisdiction, destination): + """Temporary bridges replaced screen-by-screen by the stacked migration.""" + + draft = get_current_draft(request, jurisdiction=jurisdiction) + if draft is not None and draft.workflow_version != 1: + draft.workflow_version = 1 + draft.save(update_fields=["workflow_version", "updated_at"]) + + route = { + "case_lookup": "expert_form", + "case_confirmation": "expert_form", + "document_checklist": "expert_form", + "organize_documents": "upload", + "your_information": "expert_form", + "parties": "expert_form", + "party_details": "expert_form", + "case_questions": "expert_form", + }[destination] + return redirect(route, jurisdiction=jurisdiction) diff --git a/efile_app/efile/views/upload_documents.py b/efile_app/efile/views/upload_documents.py new file mode 100644 index 0000000..0af18cb --- /dev/null +++ b/efile_app/efile/views/upload_documents.py @@ -0,0 +1,168 @@ +import logging +import os +from tempfile import NamedTemporaryFile + +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDocument +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot, read_upload_data, write_upload_data +from efile.utils.llms import LlmError, extract_fields_from_file +from efile.utils.s3_upload_handler import S3UploadHandler +from efile.views.session_api import llm_fields, llm_hints +from efile.workflow import WorkflowStepKey, get_step_url, get_workflow_context + +logger = logging.getLogger(__name__) + + +def _analyze_lead(uploaded_file, jurisdiction): + temp_path = None + try: + uploaded_file.seek(0) + with NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file: + for chunk in uploaded_file.chunks(): + temp_file.write(chunk) + temp_path = temp_file.name + return extract_fields_from_file( + temp_path, + llm_fields.get(jurisdiction, llm_fields["default"]), + llm_hint=llm_hints.get(jurisdiction, llm_hints["default"]), + ) + except LlmError: + logger.exception("Document extraction failed") + return {} + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + logger.warning("Could not remove extraction temp file %s", temp_path) + + +def _guess_payload(found_fields): + return { + "court": found_fields.get("court name"), + "filing type": found_fields.get("filing type"), + "case category": found_fields.get("case category"), + "case type": found_fields.get("case type"), + "docket number": found_fields.get("docket number") or found_fields.get("docker number"), + } + + +def _upload_files(draft, uploaded_files, jurisdiction): + handler = S3UploadHandler() + if not handler._ensure_initialized(): + raise ValueError("Document storage is not configured. Please try again later.") + + current = read_upload_data(draft) + files = current.setdefault("files", {}) + supporting = list(files.get("supporting", [])) + lead_file = None + + for uploaded_file in uploaded_files: + validation = handler.validate_file(uploaded_file, max_size_mb=10, allowed_types=[".pdf"]) + if not validation["valid"]: + raise ValueError(f"{uploaded_file.name}: {validation['error']}") + + is_lead = not files.get("lead") and lead_file is None + role = FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING + uploaded_file.seek(0) + result = handler.upload_file(uploaded_file, file_type=role) + if not result["success"]: + raise ValueError(result.get("error", f"Could not upload {uploaded_file.name}.")) + + file_data = { + "name": uploaded_file.name, + "size": uploaded_file.size, + "type": uploaded_file.content_type, + "url": handler.get_public_url(result["key"]), + "s3_key": result["key"], + } + if is_lead: + files["lead"] = file_data + lead_file = uploaded_file + else: + supporting.append(file_data) + + files["supporting"] = supporting + if lead_file is not None: + current["guesses"] = _guess_payload(_analyze_lead(lead_file, jurisdiction)) + write_upload_data(draft, current, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS) + return current + + +@require_http_methods(["GET", "POST"]) +def upload_documents(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.UPLOAD_DOCUMENTS, + workflow_version=2, + ) + + if request.method == "POST": + action = request.POST.get("action", "upload") + if action == "remove": + document_id = request.POST.get("document_id") + document = FilingDocument.objects.filter(pk=document_id, draft=draft).first() + if document is None: + return JsonResponse({"success": False, "error": "Document not found."}, status=404) + removed_lead = document.role == FilingDocument.Role.LEAD + s3_key = document.s3_key + promote_document = None + other_documents = FilingDocument.objects.filter(draft=draft).exclude(pk=document.pk) + if document.role == FilingDocument.Role.LEAD and other_documents.exists(): + replacement = other_documents.order_by("sort_order", "created_at").first() + assert replacement is not None + promote_document = replacement.pk + document.delete() + if promote_document is not None: + replacement = FilingDocument.objects.get(pk=promote_document) + replacement.role = FilingDocument.Role.LEAD + replacement.sort_order = 0 + replacement.save(update_fields=["role", "sort_order", "updated_at"]) + if removed_lead and draft.extracted_guesses: + draft.extracted_guesses = {} + draft.save(update_fields=["extracted_guesses", "updated_at"]) + if s3_key: + handler = S3UploadHandler() + if handler._ensure_initialized(): + deletion = handler.delete_file(s3_key) + if not deletion.get("success"): + logger.warning("Could not delete removed draft document %s from storage", s3_key) + return JsonResponse({"success": True}) + + uploaded_files = request.FILES.getlist("documents") + if not uploaded_files: + return JsonResponse({"success": False, "error": "Choose at least one PDF to upload."}, status=400) + try: + upload_data = _upload_files(draft, uploaded_files, jurisdiction) + except ValueError as error: + return JsonResponse({"success": False, "error": str(error)}, status=400) + return JsonResponse( + { + "success": True, + "redirect_url": get_step_url(WorkflowStepKey.EXTRACTION_REVIEW, jurisdiction), + "document_count": FilingDocument.objects.filter(draft=draft).count(), + } + ) + + upload_data = read_upload_data(draft) + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "documents": FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at"), + "has_lead_document": FilingDocument.objects.filter( + draft=draft, + role=FilingDocument.Role.LEAD, + ).exists(), + "upload_data": upload_data, + } + context.update(get_workflow_context(WorkflowStepKey.UPLOAD_DOCUMENTS, jurisdiction, draft)) + return render(request, "efile/upload_documents.html", context) diff --git a/efile_app/efile/workflow.py b/efile_app/efile/workflow.py index 46e023b..35e7a67 100644 --- a/efile_app/efile/workflow.py +++ b/efile_app/efile/workflow.py @@ -64,6 +64,18 @@ class WorkflowStage(StrEnum): REVIEW = "review" +WORKFLOW_STAGE_LABELS = { + WorkflowStage.FILING: "Filing", + WorkflowStage.UPLOAD: "Upload", + WorkflowStage.CONFIRM_CASE: "Confirm case", + WorkflowStage.CHECK_DOCUMENTS: "Check documents", + WorkflowStage.ORGANIZE_DOCUMENTS: "Organize documents", + WorkflowStage.PEOPLE: "People", + WorkflowStage.FEES: "Fees", + WorkflowStage.REVIEW: "Review", +} + + class WorkflowStepKey(StrEnum): """Stable identifiers for both reorganized and transitional workflow steps.""" @@ -102,7 +114,12 @@ class WorkflowStep: WorkflowStep(WorkflowStepKey.OPTIONS, "Options", "efile_options", WorkflowStage.FILING), WorkflowStep(WorkflowStepKey.FILING_PATH, "Filing", "filing_path", WorkflowStage.FILING), WorkflowStep(WorkflowStepKey.UPLOAD_DOCUMENTS, "Upload documents", "upload_documents", WorkflowStage.UPLOAD), - WorkflowStep(WorkflowStepKey.EXTRACTION_REVIEW, "Confirm filing", "extraction_review", WorkflowStage.UPLOAD), + WorkflowStep( + WorkflowStepKey.EXTRACTION_REVIEW, + "Confirm filing", + "extraction_review", + WorkflowStage.CONFIRM_CASE, + ), WorkflowStep(WorkflowStepKey.CASE_LOOKUP, "Find your case", "case_lookup", WorkflowStage.CONFIRM_CASE), WorkflowStep( WorkflowStepKey.CASE_CONFIRMATION, @@ -318,9 +335,25 @@ def get_workflow_context( next_step = get_next_step(current_step, draft) visible_workflow = get_visible_workflow(draft, current_step=current_step) + stages = tuple(dict.fromkeys(step.stage for step in visible_workflow)) + current_stage = get_step(current_step).stage + current_stage_index = stages.index(current_stage) + return { "workflow_steps": visible_workflow, - "workflow_stages": tuple(dict.fromkeys(step.stage for step in visible_workflow)), + "workflow_stages": stages, + "workflow_stage_progress": tuple( + { + "key": stage.value, + "label": WORKFLOW_STAGE_LABELS[stage], + "state": "complete" + if index < current_stage_index + else "current" + if index == current_stage_index + else "upcoming", + } + for index, stage in enumerate(stages) + ), "workflow_current_step": get_step(current_step), "workflow_previous_step": previous_step, "workflow_next_step": next_step,