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"" 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

 

\n

Notice

\n

 

\n

Next

") == ( + assert disclaimer_html("

 

\n

 

\n

Notice

\n

 

\n

Next

") == ( "

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: