Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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),
),
]
3 changes: 3 additions & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 26 additions & 9 deletions efile_app/efile/services/disclaimers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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):
Expand All @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion efile_app/efile/services/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 0 additions & 2 deletions efile_app/efile/services/submission_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions efile_app/efile/static/js/review.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},

Expand Down Expand Up @@ -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;
}
Expand Down
22 changes: 14 additions & 8 deletions efile_app/efile/templatetags/disclaimer_text.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -29,6 +37,4 @@ def disclaimer_html(value):
blocks = r"</?(?:p|ul|ol|li)>"
cleaned = re.sub(rf"(<br>\s*)+(?={blocks})", "", cleaned)
cleaned = re.sub(rf"({blocks})(?:\s*<br>)+", r"\1", cleaned)
cleaned = re.sub(rf"\A(?:{whitespace}|<br>)+|(?:{whitespace}|<br>)+\Z", "", cleaned)
# All external markup passed through the allowlist sanitizer above.
return SafeString(cleaned) # nosec B703
return re.sub(rf"\A(?:{whitespace}|<br>)+|(?:{whitespace}|<br>)+\Z", "", cleaned)
9 changes: 9 additions & 0 deletions efile_app/efile/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: [])
12 changes: 12 additions & 0 deletions efile_app/efile/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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,
}
59 changes: 43 additions & 16 deletions efile_app/efile/tests/test_disclaimers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pytest
import requests
from django.core.cache import cache
from django.http import JsonResponse
from django.urls import reverse

Expand All @@ -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 <private> data."}]
Expand Down Expand Up @@ -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
Expand All @@ -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 <private> data."
assert acceptance["accepted_at"]

Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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 "&lt;p&gt;" 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():
Expand All @@ -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"<p>Read <a href=\"https://example.com\">the rule</a>.</p>\nNext"
text = disclaimers.court_disclaimers(submission_draft)[0]["text"]
assert text == '<p>Read <a href="https://example.com">the rule</a>.</p>\nNext'


def test_disclaimer_html_handles_plain_text():
from efile.templatetags.disclaimer_text import disclaimer_html

assert disclaimer_html(r"<p>Read <a href=\"https://example.com\">the rule</a>.</p>\nNext") == (
assert disclaimer_html('<p>Read <a href="https://example.com">the rule</a>.</p>\nNext') == (
'<p>Read <a href="https://example.com">the rule</a>.</p>Next'
)
assert disclaimer_html("First line\nSecond line") == "First line<br>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"<p>&nbsp;</p>\n<p>&nbsp;</p>\n<p>Notice</p>\n<p>&nbsp;</p>\n<p>Next</p>") == (
assert disclaimer_html("<p>&nbsp;</p>\n<p>&nbsp;</p>\n<p>Notice</p>\n<p>&nbsp;</p>\n<p>Next</p>") == (
"<p>Notice</p><p>Next</p>"
)
assert disclaimer_html("First\n\n\nSecond") == "First<br>Second"
Expand All @@ -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"]
Expand All @@ -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."
Expand Down
Loading
Loading