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