diff --git a/efile_app/efile/migrations/0027_filingdraft_disclaimer_acceptance.py b/efile_app/efile/migrations/0027_filingdraft_disclaimer_acceptance.py
new file mode 100644
index 00000000..7a420c28
--- /dev/null
+++ b/efile_app/efile/migrations/0027_filingdraft_disclaimer_acceptance.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.5 on 2026-09-30 13:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('efile', '0026_filingdraft_quoted_fee_fingerprint'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='filingdraft',
+ name='disclaimer_acceptance',
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ ]
diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py
index ac0e2d96..71850324 100644
--- a/efile_app/efile/models.py
+++ b/efile_app/efile/models.py
@@ -274,6 +274,9 @@ class Status(models.TextChoices):
# structured JSON field rather than a column each. Only config-defined keys are
# stored here -- it is not a catch-all for arbitrary case data.
supplemental_fields = models.JSONField(default=dict, blank=True)
+ # The court requirements the filer accepted at submit, with who and when
+ # (see efile.services.disclaimers). Written with the submission claim.
+ disclaimer_acceptance = models.JSONField(default=dict, blank=True)
submission_response = models.JSONField(default=dict, blank=True)
submitted_at = models.DateTimeField(blank=True, null=True)
diff --git a/efile_app/efile/services/disclaimers.py b/efile_app/efile/services/disclaimers.py
index 4b0fd58e..74eeabd8 100644
--- a/efile_app/efile/services/disclaimers.py
+++ b/efile_app/efile/services/disclaimers.py
@@ -5,20 +5,34 @@
import requests
from django.conf import settings
from django.core import signing
+from django.core.cache import cache
from django.utils import timezone
+# Review may show text this old; submit always rechecks it against the court.
+DISCLAIMER_TTL_SECONDS = 600
+
class DisclaimerUnavailable(ValueError):
pass
-def court_disclaimers(draft):
+def _unescape(text):
+ # Some proxy code tables contain literal backslash escapes inside the JSON
+ # string, including quotes around link attributes and paragraph separators.
+ return text.replace('\\"', '"').replace("\\n", "\n")
+
+
+def court_disclaimers(draft, *, fresh=False):
+ """The court's requirements, from cache unless ``fresh``; a fresh fetch refreshes the cache."""
if not draft.court_code:
raise DisclaimerUnavailable("Choose a court to see its filing requirements.")
- url = (
- f"{settings.EFSP_URL}/jurisdictions/{quote(draft.jurisdiction, safe='')}/codes/courts/"
- f"{quote(draft.court_code, safe='')}/disclaimer_requirements"
- )
+ path = f"{quote(draft.jurisdiction, safe='')}/codes/courts/{quote(draft.court_code, safe='')}"
+ cache_key = f"court-disclaimers:{path}"
+ if not fresh:
+ cached = cache.get(cache_key)
+ if cached is not None:
+ return cached
+ url = f"{settings.EFSP_URL}/jurisdictions/{path}/disclaimer_requirements"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
@@ -29,21 +43,24 @@ def court_disclaimers(draft):
for row in rows:
if not isinstance(row, dict) or not row.get("code") or not isinstance(row.get("requirementText"), str):
raise ValueError("Invalid court requirement")
- if not row["requirementText"].strip():
+ text = _unescape(row["requirementText"])
+ if not text.strip():
raise ValueError("Empty court requirement")
requirements.append(
{
"code": str(row["code"]),
"name": str(row.get("name") or ""),
- "text": row["requirementText"],
+ "text": text,
"order": int(row.get("listorder") or 0),
}
)
- return sorted(requirements, key=lambda item: (item["order"], item["code"]))
+ requirements.sort(key=lambda item: (item["order"], item["code"]))
except (requests.RequestException, ValueError, TypeError) as error:
raise DisclaimerUnavailable(
"We could not load the court's filing requirements. Reload this page to try again."
) from error
+ cache.set(cache_key, requirements, DISCLAIMER_TTL_SECONDS)
+ return requirements
def _agreement(draft, requirements):
@@ -69,7 +86,7 @@ def disclaimer_context(draft):
def validate_acceptance(draft, payload):
"""Recheck the current court text; a stale page cannot accept changed requirements."""
- requirements = court_disclaimers(draft)
+ requirements = court_disclaimers(draft, fresh=True)
agreement = _agreement(draft, requirements)
try:
accepted = signing.loads(payload.get("disclaimer_token", ""), salt="court-disclaimers")
diff --git a/efile_app/efile/services/handoff.py b/efile_app/efile/services/handoff.py
index 5e966354..76d69986 100644
--- a/efile_app/efile/services/handoff.py
+++ b/efile_app/efile/services/handoff.py
@@ -585,7 +585,15 @@ def create_correction(draft, detail, fields):
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"]
+ original,
+ exclude=[
+ "id",
+ "correction_of",
+ "submission_snapshot",
+ "clerk_return",
+ "correction_fields",
+ "disclaimer_acceptance",
+ ],
)
values["user_id"] = values.pop("user")
values["plan_id"] = values.pop("plan")
diff --git a/efile_app/efile/services/submission_errors.py b/efile_app/efile/services/submission_errors.py
index 5472a4c6..fb77ab53 100644
--- a/efile_app/efile/services/submission_errors.py
+++ b/efile_app/efile/services/submission_errors.py
@@ -4,7 +4,6 @@
class SubmissionErrorCode:
"""Machine-readable codes for errors returned before filing submission."""
- CONFIRMATION_REQUIRED = "submission_confirmation_required"
CASE_DATA_MISSING = "submission_case_data_missing"
UPLOAD_DATA_MISSING = "submission_upload_data_missing"
EFILE_DATA_MISSING = "submission_efile_data_missing"
@@ -16,7 +15,6 @@ class SubmissionErrorCode:
PRE_SUBMIT_ERROR_CODES = frozenset(
{
- SubmissionErrorCode.CONFIRMATION_REQUIRED,
SubmissionErrorCode.CASE_DATA_MISSING,
SubmissionErrorCode.UPLOAD_DATA_MISSING,
SubmissionErrorCode.EFILE_DATA_MISSING,
diff --git a/efile_app/efile/static/js/review.js b/efile_app/efile/static/js/review.js
index 331436d1..82bcc0f7 100644
--- a/efile_app/efile/static/js/review.js
+++ b/efile_app/efile/static/js/review.js
@@ -25,13 +25,18 @@ const USABLE_FEE_STATES = ["current", "waived"];
const FilingHandler = {
feeQuoteState: "missing",
+ // A disabled checkbox means the court requirements could not be loaded.
+ filingConfirmed() {
+ const checkbox = document.getElementById("confirm-filing");
+ return checkbox.checked && !checkbox.disabled;
+ },
+
setSubmissionState(submitting) {
document.getElementById("loadingSpinner").style.display = submitting ? "block" : "none";
// Never against a total the filer has not seen for this filing: the
// button waits until the quote on the page is the current one.
document.getElementById("submitButton").disabled = submitting ||
- !document.getElementById("confirm-filing").checked ||
- document.getElementById("confirm-filing").disabled ||
+ !this.filingConfirmed() ||
!USABLE_FEE_STATES.includes(this.feeQuoteState);
},
@@ -137,7 +142,7 @@ const FilingHandler = {
},
async submitFiling() {
- if (!document.getElementById("confirm-filing").checked || document.getElementById("confirm-filing").disabled) {
+ if (!this.filingConfirmed()) {
Messages.showError(gettext("Confirm that you reviewed the filing before you submit."));
return;
}
diff --git a/efile_app/efile/templatetags/disclaimer_text.py b/efile_app/efile/templatetags/disclaimer_text.py
index 0575ea8d..a7c90474 100644
--- a/efile_app/efile/templatetags/disclaimer_text.py
+++ b/efile_app/efile/templatetags/disclaimer_text.py
@@ -1,24 +1,32 @@
"""Display court-authored notices with a small, sanitized HTML vocabulary."""
import re
+from functools import lru_cache
import bleach
+from bleach.sanitizer import ALLOWED_PROTOCOLS
from django import template
from django.utils.safestring import SafeString
+from efile.templatetags.md_to_html import ALLOWED_MARKDOWN_ATTRIBUTES
+
register = template.Library()
@register.filter
def disclaimer_html(value):
- # Some proxy code tables contain literal backslash escapes inside the JSON
- # string, including quotes around link attributes and paragraph separators.
- text = str(value or "").replace('\\"', '"').replace("\\n", "\n")
+ # All external markup passes through the allowlist sanitizer in _sanitize.
+ return SafeString(_sanitize(str(value or ""))) # nosec B703
+
+
+# The same court and state notices render on every Review and Upload page.
+@lru_cache(maxsize=256)
+def _sanitize(text):
cleaned = bleach.clean(
text,
tags={"p", "br", "strong", "b", "em", "i", "ul", "ol", "li", "a"},
- attributes={"a": ["href", "title"]},
- protocols={"https", "http", "mailto"},
+ attributes=ALLOWED_MARKDOWN_ATTRIBUTES,
+ protocols=ALLOWED_PROTOCOLS,
strip=True,
)
# Drop spacer paragraphs and redundant breaks; site CSS owns block spacing.
@@ -29,6 +37,4 @@ def disclaimer_html(value):
blocks = r"?(?:p|ul|ol|li)>"
cleaned = re.sub(rf"(
\s*)+(?={blocks})", "", cleaned)
cleaned = re.sub(rf"({blocks})(?:\s*
)+", r"\1", cleaned)
- cleaned = re.sub(rf"\A(?:{whitespace}|
)+|(?:{whitespace}|
)+\Z", "", cleaned)
- # All external markup passed through the allowlist sanitizer above.
- return SafeString(cleaned) # nosec B703
+ return re.sub(rf"\A(?:{whitespace}|
)+|(?:{whitespace}|
)+\Z", "", cleaned)
diff --git a/efile_app/efile/tests/conftest.py b/efile_app/efile/tests/conftest.py
index dd7111dc..5e92f4a2 100644
--- a/efile_app/efile/tests/conftest.py
+++ b/efile_app/efile/tests/conftest.py
@@ -20,3 +20,12 @@ def stand_in_document_disabled_by_default():
"""
with override_settings(EFSP_TEST_DOCUMENT_URL=""):
yield
+
+
+@pytest.fixture(autouse=True)
+def court_requirements(monkeypatch):
+ """Stand in for the court's disclaimer lookup so no test calls the live EFSP.
+
+ Tests of the lookup itself override this fixture with one of the same name.
+ """
+ monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft, fresh=False: [])
diff --git a/efile_app/efile/tests/helpers.py b/efile_app/efile/tests/helpers.py
new file mode 100644
index 00000000..2cd5ff9e
--- /dev/null
+++ b/efile_app/efile/tests/helpers.py
@@ -0,0 +1,12 @@
+"""Shared helpers for tests that drive the submit path."""
+
+from efile.services.disclaimers import disclaimer_context
+
+
+def accepted_submission(draft, **fields):
+ """A submit body that accepts the court requirements shown on Review for this draft."""
+ return {
+ "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
+ "confirm_submission": True,
+ **fields,
+ }
diff --git a/efile_app/efile/tests/test_disclaimers.py b/efile_app/efile/tests/test_disclaimers.py
index 6d728c7e..2109ffce 100644
--- a/efile_app/efile/tests/test_disclaimers.py
+++ b/efile_app/efile/tests/test_disclaimers.py
@@ -3,6 +3,7 @@
import pytest
import requests
+from django.core.cache import cache
from django.http import JsonResponse
from django.urls import reverse
@@ -12,6 +13,17 @@
submission_draft = test_review_submit_flow.submission_draft
+@pytest.fixture
+def court_requirements():
+ """Use the real court lookup here instead of the suite-wide stand-in, with nothing cached."""
+ cache.clear()
+
+
+def _assert_no_court_lookup():
+ calls = cast(Mock, disclaimers.requests.get).call_args_list
+ assert not any("/disclaimer_requirements" in call.args[0] for call in calls)
+
+
@pytest.fixture
def requirements(monkeypatch):
rows = [{"code": "privacy", "name": "Privacy", "listorder": 1, "requirementText": "Redact data."}]
@@ -68,7 +80,7 @@ def test_outage_disables_review(client, submission_draft, requirements):
def test_submission_requires_and_records_acceptance(client, submission_draft, requirements, monkeypatch):
monkeypatch.setattr("efile.views.submission.fee_quote_is_usable", lambda draft: True)
upstream = Mock(return_value=JsonResponse({"success": True, "api_response": {}}))
- monkeypatch.setattr("efile.views.submission.legacy_submit_final_filing", upstream)
+ monkeypatch.setattr("efile.views.submission.forward_final_filing", upstream)
url = "/api/submit-final-filing/"
response = client.post(url, {"confirm_submission": True}, content_type="application/json")
assert response.status_code == 400
@@ -79,7 +91,7 @@ def test_submission_requires_and_records_acceptance(client, submission_draft, re
)
assert response.status_code == 200
submission_draft.refresh_from_db()
- acceptance = submission_draft.supplemental_fields["_disclaimer_acceptance"]
+ acceptance = submission_draft.disclaimer_acceptance
assert acceptance["requirements"][0]["text"] == "Redact data."
assert acceptance["accepted_at"]
@@ -110,9 +122,7 @@ def test_upload_uses_state_notices_without_court_lookup(client, submission_draft
assert "Before you upload your documents" in content
assert "Protect information." in content
assert "State notice" in content
- assert not any(
- "/disclaimer_requirements" in call.args[0] for call in cast(Mock, disclaimers.requests.get).call_args_list
- )
+ _assert_no_court_lookup()
@pytest.mark.django_db
@@ -121,9 +131,7 @@ def test_upload_hides_unconfigured_notices(client, submission_draft, requirement
content = client.get(reverse("upload_documents", kwargs={"jurisdiction": "illinois"})).content.decode()
assert "Before you upload your documents" not in content
assert "Choose a court" not in content
- assert not any(
- "/disclaimer_requirements" in call.args[0] for call in cast(Mock, disclaimers.requests.get).call_args_list
- )
+ _assert_no_court_lookup()
def test_state_notices_are_configured_and_independent():
@@ -149,9 +157,7 @@ def test_illinois_upload_displays_standard_notices_before_court_selection(client
assert "Supreme Court Rule 138" in content
assert "Social Security Numbers" in content
assert "<p>" not in content
- assert not any(
- "/disclaimer_requirements" in call.args[0] for call in cast(Mock, disclaimers.requests.get).call_args_list
- )
+ _assert_no_court_lookup()
def test_disclaimer_html_preserves_formatting_and_removes_unsafe_markup():
@@ -169,19 +175,40 @@ def test_disclaimer_html_preserves_formatting_and_removes_unsafe_markup():
assert unsafe not in rendered
-def test_disclaimer_html_handles_proxy_escapes_and_plain_text():
+@pytest.mark.django_db
+def test_proxy_escapes_are_removed_before_display_and_acceptance(submission_draft, requirements):
+ requirements[0]["requirementText"] = r"Read the rule.
\nNext"
+ text = disclaimers.court_disclaimers(submission_draft)[0]["text"]
+ assert text == 'Read the rule.
\nNext'
+
+
+def test_disclaimer_html_handles_plain_text():
from efile.templatetags.disclaimer_text import disclaimer_html
- assert disclaimer_html(r"Read the rule.
\nNext") == (
+ assert disclaimer_html('Read the rule.
\nNext') == (
'Read the rule.
Next'
)
assert disclaimer_html("First line\nSecond line") == "First line
Second line"
+@pytest.mark.django_db
+def test_review_reuses_the_lookup_but_submit_rechecks_the_court(submission_draft, requirements):
+ get = cast(Mock, disclaimers.requests.get)
+ token = disclaimers.disclaimer_context(submission_draft)["disclaimer_token"]
+ disclaimers.disclaimer_context(submission_draft)
+ assert get.call_count == 1
+ requirements[0]["requirementText"] = "Updated requirement"
+ with pytest.raises(ValueError, match="Review and accept"):
+ disclaimers.validate_acceptance(submission_draft, {"confirm_submission": True, "disclaimer_token": token})
+ assert get.call_count == 2
+ # The recheck refreshed the cache, so reloading Review shows the new text.
+ assert disclaimers.court_disclaimers(submission_draft)[0]["text"] == "Updated requirement"
+
+
def test_disclaimer_spacing_is_not_controlled_by_empty_court_paragraphs():
from efile.templatetags.disclaimer_text import disclaimer_html
- assert disclaimer_html(r"
\n
\nNotice
\n
\nNext
") == (
+ assert disclaimer_html("
\n
\nNotice
\n
\nNext
") == (
"Notice
Next
"
)
assert disclaimer_html("First\n\n\nSecond") == "First
Second"
@@ -190,7 +217,7 @@ def test_disclaimer_spacing_is_not_controlled_by_empty_court_paragraphs():
@pytest.mark.django_db
def test_submission_without_draft_does_not_reach_tyler(client, monkeypatch):
upstream = Mock()
- monkeypatch.setattr("efile.views.submission.legacy_submit_final_filing", upstream)
+ monkeypatch.setattr("efile.views.submission.forward_final_filing", upstream)
response = client.post("/api/submit-final-filing/", {"confirm_submission": True}, content_type="application/json")
assert response.status_code == 400
assert "Open your filing" in response.json()["error"]
@@ -201,7 +228,7 @@ def test_submission_without_draft_does_not_reach_tyler(client, monkeypatch):
@pytest.mark.parametrize("body", ["", "{broken", "[]", "null"])
def test_malformed_submission_has_friendly_error(client, submission_draft, monkeypatch, body):
upstream = Mock()
- monkeypatch.setattr("efile.views.submission.legacy_submit_final_filing", upstream)
+ monkeypatch.setattr("efile.views.submission.forward_final_filing", upstream)
response = client.post("/api/submit-final-filing/", body, content_type="application/json")
assert response.status_code == 400
assert response.json()["error"] == "We could not read your submission. Reload the review page and try again."
diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py
index 6286f5b1..4f9c8052 100644
--- a/efile_app/efile/tests/test_durable_drafts.py
+++ b/efile_app/efile/tests/test_durable_drafts.py
@@ -5,7 +5,6 @@
from efile.models import FilingDocument, FilingDraft, FilingParty, FilingPlan
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY, get_current_draft
-from efile.services.disclaimers import disclaimer_context
from efile.services.drafts import (
draft_snapshot,
read_case_data,
@@ -15,14 +14,10 @@
)
from efile.services.efsp_payload import PayloadValidationError
from efile.services.fee_quotes import record_fee_quote
+from efile.tests.helpers import accepted_submission
from efile.workflow import WorkflowStepKey, get_workflow_step_choices
-@pytest.fixture(autouse=True)
-def court_requirements(monkeypatch):
- monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft: [])
-
-
class FakeApiResponse:
def __init__(self, status_code, payload):
self.status_code = status_code
@@ -499,11 +494,7 @@ def fake_post(*_args, **_kwargs):
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
@@ -546,11 +537,7 @@ def fake_post(*_args, **_kwargs):
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
@@ -572,11 +559,7 @@ def fake_post(*_args, **_kwargs):
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
@@ -603,11 +586,11 @@ def test_submission_claim_prevents_duplicate_filing(django_user_model):
user = django_user_model.objects.create_user(username="claim-user", tyler_jurisdiction="illinois")
draft = FilingDraft.objects.create(user=user, jurisdiction="illinois")
- assert _claim_for_submission(draft) is True
+ assert _claim_for_submission(draft, {}) is True
draft.refresh_from_db()
assert draft.status == FilingDraft.Status.SUBMITTING
# A second attempt on the same draft is refused.
- assert _claim_for_submission(draft) is False
+ assert _claim_for_submission(draft, {}) is False
@pytest.mark.django_db
@@ -618,11 +601,11 @@ def test_ambiguous_submission_states_are_not_automatically_reclaimed(django_user
user = django_user_model.objects.create_user(username="ambiguous-state-user", tyler_jurisdiction="illinois")
draft = FilingDraft.objects.create(user=user, jurisdiction="illinois")
- assert _claim_for_submission(draft) is True
- assert _claim_for_submission(draft) is False
+ assert _claim_for_submission(draft, {}) is True
+ assert _claim_for_submission(draft, {}) is False
draft.mark_error({"error": "outcome unknown"})
- assert _claim_for_submission(draft) is False
+ assert _claim_for_submission(draft, {}) is False
@pytest.mark.django_db
@@ -635,10 +618,7 @@ def test_precondition_failure_releases_claim_to_draft(client, django_user_model)
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- }, # no efile_data -> pre-call validation failure
+ data=accepted_submission(draft), # no efile_data -> pre-call validation failure
content_type="application/json",
)
@@ -666,11 +646,7 @@ def boom(*_args, **_kwargs):
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
@@ -680,11 +656,7 @@ def boom(*_args, **_kwargs):
retry = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
@@ -763,11 +735,7 @@ def unreachable_post(*_args, **_kwargs):
response = client.post(
reverse("submit_final_filing"),
- data={
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ data=accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
diff --git a/efile_app/efile/tests/test_end_to_end_new_flow_states.py b/efile_app/efile/tests/test_end_to_end_new_flow_states.py
index 65f79d37..0f04499d 100644
--- a/efile_app/efile/tests/test_end_to_end_new_flow_states.py
+++ b/efile_app/efile/tests/test_end_to_end_new_flow_states.py
@@ -9,15 +9,10 @@
from efile.models import DocumentExtraction, FilingDocument, FilingDraft
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
-from efile.services.disclaimers import disclaimer_context
+from efile.tests.helpers import accepted_submission
from efile.workflow import ExistingCase, WorkflowStepKey
-@pytest.fixture(autouse=True)
-def court_requirements(monkeypatch):
- monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft: [])
-
-
class FakeApiResponse:
def __init__(self, status_code, payload):
self.status_code = status_code
@@ -434,13 +429,7 @@ def fake_download(_key, destination):
):
submit_resp = client.post(
reverse("submit_final_filing"),
- data=json.dumps(
- {
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- }
- ),
+ data=json.dumps(accepted_submission(draft, efile_data={"al_court_bundle": {}})),
content_type="application/json",
)
diff --git a/efile_app/efile/tests/test_fee_quotes.py b/efile_app/efile/tests/test_fee_quotes.py
index 4cc18b63..fb42bdf6 100644
--- a/efile_app/efile/tests/test_fee_quotes.py
+++ b/efile_app/efile/tests/test_fee_quotes.py
@@ -9,7 +9,6 @@
from efile.models import FilingDocument, FilingDraft, FilingParty
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
-from efile.services.disclaimers import disclaimer_context
from efile.services.fee_quotes import (
FeeQuoteState,
fee_fingerprint,
@@ -18,6 +17,7 @@
quote_from_efsp_response,
record_fee_quote,
)
+from efile.tests.helpers import accepted_submission
from efile.workflow import WorkflowStepKey
REVIEW_URL = reverse("case_review", kwargs={"jurisdiction": "illinois"})
@@ -25,11 +25,6 @@
FEES_URL = reverse("api:payment_fees")
-@pytest.fixture(autouse=True)
-def court_requirements(monkeypatch):
- monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft: [])
-
-
def efsp_fee_response(total, *fees):
return {
"feesCalculationAmount": {"value": total},
@@ -155,13 +150,7 @@ def submit(client):
):
response = client.post(
SUBMIT_URL,
- data=json.dumps(
- {
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- }
- ),
+ data=json.dumps(accepted_submission(draft, efile_data={"al_court_bundle": {}})),
content_type="application/json",
)
return response, post
diff --git a/efile_app/efile/tests/test_filing_integrity.py b/efile_app/efile/tests/test_filing_integrity.py
index 1d9b601d..2c59c6a7 100644
--- a/efile_app/efile/tests/test_filing_integrity.py
+++ b/efile_app/efile/tests/test_filing_integrity.py
@@ -9,7 +9,6 @@
from efile.models import FilingDocument, FilingDraft, FilingParty
from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY
-from efile.services.disclaimers import disclaimer_context
from efile.services.drafts import read_case_data, write_case_data
from efile.services.extracted_parties import review_rows, save_reviewed_parties
from efile.services.extraction_fields import (
@@ -18,11 +17,7 @@
normalize_extracted_fields,
)
from efile.services.fee_quotes import record_fee_quote
-
-
-@pytest.fixture(autouse=True)
-def court_requirements(monkeypatch):
- monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft: [])
+from efile.tests.helpers import accepted_submission
@pytest.mark.parametrize(
@@ -252,11 +247,7 @@ def test_organization_and_submission_preserve_primary_type_and_confirmation_iden
with patch("requests.post", return_value=FakeApiResponse(201, {"filing_id": "adoption-123"})):
response = client.post(
reverse("submit_final_filing") + f"?draft={draft.pk}",
- {
- "disclaimer_token": disclaimer_context(draft)["disclaimer_token"],
- "confirm_submission": True,
- "efile_data": {"al_court_bundle": {}},
- },
+ accepted_submission(draft, efile_data={"al_court_bundle": {}}),
content_type="application/json",
)
assert response.status_code == 200
diff --git a/efile_app/efile/tests/test_review_submit_flow.py b/efile_app/efile/tests/test_review_submit_flow.py
index 9f36e8af..ea6e4525 100644
--- a/efile_app/efile/tests/test_review_submit_flow.py
+++ b/efile_app/efile/tests/test_review_submit_flow.py
@@ -7,11 +7,6 @@
from efile.workflow import WorkflowStepKey
-@pytest.fixture(autouse=True)
-def court_requirements(monkeypatch):
- monkeypatch.setattr("efile.services.disclaimers.court_disclaimers", lambda draft: [])
-
-
@pytest.fixture
def submission_draft(client, django_user_model):
user = django_user_model.objects.create_user(username="review-user", tyler_jurisdiction="illinois")
diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py
index 6700fefc..c11db7e5 100644
--- a/efile_app/efile/views/session_api.py
+++ b/efile_app/efile/views/session_api.py
@@ -91,23 +91,13 @@ def get_upload_data_from_session(request):
return JsonResponse(get_upload_data(request), safe=False)
-@csrf_exempt
-@require_http_methods(["POST"])
-def submit_final_filing(request):
- """Handle final filing submission after user has reviewed all information."""
- try:
- data = json.loads(request.body)
-
- if not data.get("confirm_submission"):
- return JsonResponse(
- {
- "success": False,
- "error_code": SubmissionErrorCode.CONFIRMATION_REQUIRED,
- "error": "Submission confirmation is required",
- },
- status=400,
- )
+def forward_final_filing(request, data):
+ """Send a confirmed filing to the court.
+ Only views/submission.py calls this, after it has parsed the body and
+ checked the filer's confirmation and acceptance of the court requirements.
+ """
+ try:
# Read the filing state from the current durable draft
case_data = get_case_data(request)
jurisdiction_id = request.session.get("jurisdiction") or case_data.get("jurisdiction")
@@ -155,7 +145,6 @@ def submit_final_filing(request):
# Log the complete request data for debugging
logger.debug("Complete request data received")
- logger.debug(f" - confirm_submission: {data.get('confirm_submission')}")
logger.debug(
f" - efile_data keys: {list(efile_data.keys()) if isinstance(efile_data, dict) else 'Not a dict'}"
)
@@ -286,7 +275,7 @@ def submit_final_filing(request):
{"success": False, "error": f"Network error during filing submission: {str(e)}"}, status=500
)
- except (json.JSONDecodeError, Exception) as e:
+ except Exception as e:
return JsonResponse({"success": False, "error": f"An error occurred during submission: {str(e)}"}, status=500)
diff --git a/efile_app/efile/views/submission.py b/efile_app/efile/views/submission.py
index 10d85124..3a69f51c 100644
--- a/efile_app/efile/views/submission.py
+++ b/efile_app/efile/views/submission.py
@@ -15,7 +15,7 @@
from efile.services.submission_errors import PRE_SUBMIT_ERROR_CODES, SubmissionErrorCode
from .confirmation import LAST_SUBMITTED_DRAFT_SESSION_KEY
-from .session_api import submit_final_filing as legacy_submit_final_filing
+from .session_api import forward_final_filing
logger = logging.getLogger(__name__)
@@ -26,8 +26,8 @@
_CLAIMABLE_STATUSES = (FilingDraft.Status.DRAFT,)
-def _claim_for_submission(draft: FilingDraft) -> bool:
- """Atomically move a DRAFT into SUBMITTING.
+def _claim_for_submission(draft: FilingDraft, acceptance: dict) -> bool:
+ """Atomically move a DRAFT into SUBMITTING, recording the accepted court requirements.
Only one request can win this transition, so concurrent double-clicks cannot
each forward to the external API. A draft already SUBMITTING or ERROR is not
@@ -35,10 +35,12 @@ def _claim_for_submission(draft: FilingDraft) -> bool:
"""
claimed = FilingDraft.objects.filter(pk=draft.pk, status__in=_CLAIMABLE_STATUSES).update(
status=FilingDraft.Status.SUBMITTING,
+ disclaimer_acceptance=acceptance,
updated_at=timezone.now(),
)
if claimed:
draft.status = FilingDraft.Status.SUBMITTING
+ draft.disclaimer_acceptance = acceptance
return bool(claimed)
@@ -98,7 +100,7 @@ def submit_final_filing(request):
# The filer agreed to a total on Review. If anything that prices the filing
# changed since, that total is not the one they would be charged, so the
# page has to show the new one before anything reaches the court.
- if draft is not None and not fee_quote_is_usable(draft):
+ if not fee_quote_is_usable(draft):
return JsonResponse(
{
"success": False,
@@ -109,25 +111,19 @@ def submit_final_filing(request):
status=412,
)
- acceptance = None
- if draft is not None:
- try:
- acceptance = validate_acceptance(draft, submission_data)
- except (ValueError, TypeError, AttributeError) as error:
- return JsonResponse({"success": False, "error": str(error)}, status=400)
+ try:
+ acceptance = validate_acceptance(draft, submission_data)
+ except ValueError as error:
+ return JsonResponse({"success": False, "error": str(error)}, status=400)
# Claim the draft before forwarding so a concurrent request can't file twice.
- if draft is not None and not _claim_for_submission(draft):
+ if not _claim_for_submission(draft, acceptance):
return JsonResponse(
{"success": False, "error": "This filing can't be submitted again automatically."},
status=409,
)
- if draft is not None and acceptance is not None:
- draft.supplemental_fields = {**(draft.supplemental_fields or {}), "_disclaimer_acceptance": acceptance}
- draft.save(update_fields=["supplemental_fields", "updated_at"])
-
- response = legacy_submit_final_filing(request)
+ response = forward_final_filing(request, submission_data)
payload = _json_payload(response)
if response.status_code < 400 and payload.get("success") is True: