diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43fae6d..41a24c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,6 +10,49 @@ repos: # Ty: Rust-based Python type checker (configured in pyproject.toml under [tool.ty]) - repo: local hooks: + # Keep the non-Python assets in sync with the formatters used by CI. + - id: djlint-format + name: djlint (format) + entry: bash -c + language: system + files: ^efile_app/.*\.html$ + args: + - | + files=() + for file in "$@"; do + files+=("${file#efile_app/}") + done + (cd efile_app && uv run djlint --reformat "${files[@]}") + - -- + + - id: css-beautify + name: css-beautify (format) + entry: bash -c + language: system + files: ^efile_app/.*\.css$ + args: + - | + files=() + for file in "$@"; do + files+=("${file#efile_app/}") + done + (cd efile_app && uv run css-beautify -r "${files[@]}") + - -- + + - id: js-beautify + name: js-beautify (format) + entry: bash -c + language: system + files: ^efile_app/.*\.js$ + args: + - | + files=() + for file in "$@"; do + files+=("${file#efile_app/}") + done + (cd efile_app && uv run js-beautify -r "${files[@]}") + - -- + - id: ty name: ty (type check) entry: bash -lc diff --git a/README.md b/README.md index c8c8519..ca7ca45 100644 --- a/README.md +++ b/README.md @@ -242,8 +242,9 @@ of the current end-to-end testing. - __Environment variables__: Create a `.env` file in the `efile_app/` directory with: ```bash - E2E_TEST_USERNAME=your_test_email@example.com - E2E_TEST_PASSWORD=your_test_password + # Tyler test-EFM login. The Python test suite reads the same two names. + TESTS_TYLER_USERNAME=your_test_email@example.com + TESTS_TYLER_PASSWORD=your_test_password E2E_TEST_BASE_URL=http://localhost:8000 # optional, defaults to localhost:8000 ``` diff --git a/compose.yml b/compose.yml index 86c159a..f1cd635 100644 --- a/compose.yml +++ b/compose.yml @@ -1,21 +1,67 @@ services: + localstack: + # Keep the pre-auth Community Edition for local development. Newer + # calendar-versioned images require a LocalStack account token. + image: localstack/localstack:3.8.0 + ports: + - "127.0.0.1:4566:4566" + - "127.0.0.1:4510-4559:4510-4559" + environment: + DEBUG: "${DEBUG:-0}" + SERVICES: s3 + AWS_DEFAULT_REGION: us-east-1 + AWS_S3_BUCKET_NAME: forms-mvp-xf6361 + volumes: + - localstack:/var/lib/localstack + - /var/run/docker.sock:/var/run/docker.sock + - ./testing/localstack-init:/etc/localstack/init/ready.d:ro + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 5s + timeout: 5s + retries: 20 + web: build: context: . dockerfile: Dockerfile image: form-submission-mvp:web command: >- - sh -c "uv run python efile_app/manage.py migrate && - uv run python efile_app/manage.py runserver 0.0.0.0:8000" + sh -c "uv run python manage.py migrate --noinput --fake-initial && + uv run python manage.py runserver 0.0.0.0:8000" + depends_on: + localstack: + condition: service_healthy ports: - "8000:8000" environment: PYTHONDONTWRITEBYTECODE: "1" PYTHONUNBUFFERED: "1" + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_S3_BUCKET_NAME: forms-mvp-xf6361 + AWS_S3_REGION_NAME: us-east-1 + AWS_S3_ENDPOINT_URL: http://localstack:4566 + # botocore >= 1.35 routes S3 through account-ID-specific endpoints, which + # LocalStack does not serve. Disable that routing. + AWS_ACCOUNT_ID_ENDPOINT_MODE: disabled + # A publicly readable PDF handed to the EFSP proxy in place of the uploaded + # document, so fee quotes work without exposing this machine. The proxy + # fetches every data_url itself and cannot resolve `localstack`, so a real + # presigned URL fails here with a 400. See testing/README.md. + # + # Read from the shell or the compose-level .env, NOT efile_app/.env: this + # value lands in the container environment, and settings_dev loads that + # .env with override=False, so whatever is set here wins. `-` rather than + # `:-` so an explicit empty value still turns the substitution off. + EFSP_TEST_DOCUMENT_URL: "${EFSP_TEST_DOCUMENT_URL-https://raw.githubusercontent.com/SuffolkLITLab/LITEFile/main/testing/sample_test.pdf}" # DJANGO_SETTINGS_MODULE: efile.settings # manage.py sets this; uncomment to override volumes: - .:/app:delegated - - venv:/app/.venv + # The image builds its venv at /app/efile_app/.venv (Dockerfile WORKDIR). + # This must shadow that exact path, or the bind mount above exposes the + # host's venv to the container and root-owned files land in the checkout. + - venv:/app/efile_app/.venv # healthcheck: # test: ["CMD-SHELL", "wget -qO- http://localhost:8000/ || exit 1"] # interval: 10s @@ -23,5 +69,7 @@ services: # retries: 5 volumes: + localstack: + driver: local venv: driver: local diff --git a/efile_app/.env.example b/efile_app/.env.example index 918f7e3..244ea35 100644 --- a/efile_app/.env.example +++ b/efile_app/.env.example @@ -6,20 +6,33 @@ DJANGO_LOG_LEVEL=DEBUG DATABASE_URL=postgresql://user:password@localhost:5432/dbname SUFFOLK_EFILE_API_KEY = "your-suffolk-efile-api-key-here" +EFSP_URL = "https://efile-test.suffolklitlab.org" # Local development only. The EFSP proxy downloads each document itself -- for # fee quotes as well as filings -- and cannot reach a LocalStack URL. Set this to # any publicly readable PDF to run fees end-to-end without exposing this machine. -# Defaults to the sample test PDF hosted on GitHub. +# Uploads still go to S3 for real; only the URL sent to the proxy changes. +# Set to "" to send the real document URL. See ../testing/README.md. +# +# Only read when running the app directly (manage.py runserver). Under +# `docker compose`, compose.yml sets this in the container environment and +# settings_dev loads this file with override=False, so set it in the shell or +# the repo-root .env instead. EFSP_TEST_DOCUMENT_URL = "https://raw.githubusercontent.com/SuffolkLITLab/LITEFile/main/testing/sample_test.pdf" # AWS S3 Configuration -AWS_S3_ENDPOINT_URL = "http://host.docker.internal:4566" # if mocking S3 locally, see [testing README](../testing/README.md). +AWS_S3_ENDPOINT_URL = "http://localstack:4566" # if mocking S3 locally, see [testing README](../testing/README.md). AWS_ACCESS_KEY_ID = "your-aws-access-key-id-here" AWS_SECRET_ACCESS_KEY = r"your-aws-secret-access-key-here" # Raw string to handle special chars AWS_S3_BUCKET_NAME = "forms-mvp-xf6361" AWS_S3_REGION_NAME = "us-east-1" +# Tyler test-EFM login, shared by the Python suite (efile/tests/) and the +# Playwright suite (tests/). CI supplies the same two names from repo secrets. +TESTS_TYLER_USERNAME = "your_test_email@example.com" +TESTS_TYLER_PASSWORD = "your_test_password" +E2E_TEST_BASE_URL = "http://localhost:8000" # Playwright only; optional + # File Upload Settings MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB ALLOWED_FILE_TYPES = ['.pdf', '.doc', '.docx'] diff --git a/efile_app/efile/api/filing_views.py b/efile_app/efile/api/filing_views.py index 2b3fc65..4fce663 100644 --- a/efile_app/efile/api/filing_views.py +++ b/efile_app/efile/api/filing_views.py @@ -13,8 +13,10 @@ from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request +from ..services.efsp_errors import describe_efsp_error +from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..utils.case_data_utils import get_case_data -from ..utils.proxy_connection import get_headers, get_party_type_code_from_api +from ..utils.proxy_connection import get_headers from .base import APIResponseMixin logger = logging.getLogger(__name__) @@ -172,8 +174,18 @@ def payment_fees(request): jurisdiction_id = request.session.get("jurisdiction") auth_tokens = request.session.get("auth_tokens", {}) - case_data = request.session.get("case_data", {}) + case_data = get_case_data(request, jurisdiction_id) court_id = case_data.get("court", "") + + # Must match what submit_final_filing sends, or fees are quoted + # against a payload that differs from the one actually filed. + try: + prepare_efile_payload(efile_data, jurisdiction_id, court_id) + except PayloadValidationError as error: + # Known-bad payload: answer with the specific reason rather than + # letting the EFSP reply with a code-list error no filer can act on. + return JsonResponse({"success": False, "error": str(error)}, status=400) + url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/filingreview/courts/{court_id}/filing/fees" headers = get_headers() @@ -190,7 +202,7 @@ def payment_fees(request): logger.warning(f"No Tyler token found for jurisdiction '{jurisdiction_id}' in filing submission") logger.info(f"Making request!: {url}") - response = requests.post(url, json=efile_data, headers=headers) + response = requests.post(url, json=efile_data, headers=headers, timeout=60) logger.info(f"Made request: {response.status_code}") if response.status_code == 200 or response.status_code == 201: @@ -205,28 +217,14 @@ def payment_fees(request): } ) else: - try: - error_data = response.json() - error_message = error_data.get("error", f"API returned status {response.status_code}") - logger.info(f"Sending back: {error_data}, {error_message}") - - # For 400 errors, include more details - if response.status_code == 400: - validation_errors = error_data.get("validation_errors", error_data.get("errors", [])) - if validation_errors: - error_message += f" - Validation errors: {validation_errors}" - - except json.JSONDecodeError: - error_message = f"API returned status {response.status_code} - Response: {response.text}" - except Exception as parse_error: - error_message = ( - f"API returned status {response.status_code} - Could not parse response: {str(parse_error)}" - ) + # Debug, not info: fee responses echo party names and case details. + logger.debug("EFSP fee response body: %s", response.text[:2000]) + error_message = describe_efsp_error(response) return JsonResponse( { "success": False, - "error": f"Filing submission failed: {error_message}", + "error": f"Could not get filing fees: {error_message}", "api_status_code": response.status_code, "api_response": response.text[:500] if response.text else "No response body", }, @@ -321,285 +319,6 @@ def delete_filing(request, filing_id): return FilingAPIViews.error_response(f"Error: {str(e)}") -@csrf_exempt -@require_http_methods(["POST"]) -def _unused_create_filing(request): - """Create a filing with Suffolk LIT Lab API using collected case data.""" - - try: - # Get case data from session - case_data = get_case_data(request) - - if not case_data: - return JsonResponse( - {"success": False, "error": "No case data found. Please complete the expert form first."}, status=400 - ) - - # Get auth tokens - auth_tokens = request.session.get("auth_tokens") - if not auth_tokens or "token" not in auth_tokens: - return JsonResponse( - {"success": False, "error": "Authentication required. Please log in first."}, status=401 - ) - - # Transform case data to Suffolk API payload format - filing_payload = transform_case_data_to_filing_payload(case_data, request) - - # Make POST request to Suffolk LIT Lab filing API - path = "/filings/" - api_url = f"{settings.EFSP_URL}{path}" - - headers = {"Authorization": f"Bearer {auth_tokens['token']}", "Content-Type": "application/json"} - - # Safe pre-request logging - logger.debug( - "POST %s with headers keys=%s payload keys=%s", - api_url, - list(headers.keys()), - list(filing_payload.keys()), - ) - response = requests.post(api_url, headers=headers, json=filing_payload, timeout=30) - logger.debug( - "Create filing response: status=%s content_type=%s", - response.status_code, - response.headers.get("Content-Type"), - ) - - if response.status_code == 201: - # Filing created successfully - filing_data = response.json() - - # Save filing ID to session for future reference - request.session["current_filing_id"] = filing_data.get("id") - request.session.modified = True - - return JsonResponse( - { - "success": True, - "filing_id": filing_data.get("id"), - "message": "Filing created successfully", - "data": filing_data, - } - ) - else: - # API error - error_detail = response.text - try: - error_json = response.json() - error_detail = error_json.get("detail", error_json) - except ValueError: - pass - - return JsonResponse( - { - "success": False, - "error": f"Filing creation failed: {error_detail}", - "status_code": response.status_code, - }, - status=response.status_code, - ) - - except requests.RequestException as e: - return JsonResponse({"success": False, "error": f"Network error: {str(e)}"}, status=500) - except Exception as e: - return JsonResponse({"success": False, "error": f"Unexpected error: {str(e)}"}, status=500) - - -def transform_case_data_to_filing_payload(case_data, request=None): - """ - Transform collected case data into Suffolk LIT Lab API filing payload format. - """ - - # Base filing payload structure - payload = { - "jurisdiction": case_data.get("jurisdiction"), - "court": case_data.get("court"), - "category": case_data.get("case_category"), - "case_type": case_data.get("case_type"), - "filing_type": case_data.get("filing_type"), - "document_type": case_data.get("document_type"), - "parties": [], - "optional_services": case_data.get("optional_services", []), - } - - # Add petitioner party if this is a name change case - if "name change" in case_data.get("case_type", "").lower(): - # Add petitioner - if case_data.get("petitioner_first_name") or case_data.get("petitioner_last_name"): - # Use party type from session data, or determine it from API - party_type = case_data.get("petitioner_party_type") - if not party_type: - party_type = determine_party_type_for_new_case(case_data) - - petitioner = { - "party_type": party_type, - "name": { - "first": case_data.get("petitioner_first_name", ""), - "last": case_data.get("petitioner_last_name", ""), - "full": ( - f"{case_data.get('petitioner_first_name', '')} {case_data.get('petitioner_last_name', '')}" - ).strip(), - }, - "address": case_data.get("petitioner_address", ""), - "role": "Petitioner", # Keep role as readable text - } - payload["parties"].append(petitioner) - else: - # For non-name change cases, still add the party information if available - if case_data.get("first_name") or case_data.get("last_name"): - # Determine party type based on existing case status - existing_case = None - if request: - existing_case = request.session.get("existing_case") - - # Also check if existing_case is stored in case_data - if not existing_case: - existing_case = case_data.get("existing_case") - - if existing_case == "yes": - # When responding to existing case, use intelligent party type determination - party_type = case_data.get("party_type") or determine_party_type_for_existing_case(case_data) - else: - # For new cases, use intelligent party type determination - party_type = case_data.get("party_type") or determine_party_type_for_new_case(case_data) - - # Determine role name for display (keep as readable text) - if existing_case == "yes": - role_name = "Defendant" if "DEF" in party_type else "Respondent" if "RES" in party_type else "Party" - else: - role_name = "Petitioner" if "PET" in party_type else "Plaintiff" if "PLA" in party_type else "Party" - - party = { - "party_type": party_type, - "name": { - "first": case_data.get("first_name", ""), - "last": case_data.get("last_name", ""), - "full": (f"{case_data.get('first_name', '')} {case_data.get('last_name', '')}").strip(), - }, - "address": case_data.get("address", ""), - "role": role_name, - } - payload["parties"].append(party) - - # Add name sought information as additional case details - if case_data.get("new_first_name") or case_data.get("new_last_name"): - payload["name_change_details"] = { - "new_name": { - "first": case_data.get("new_first_name", ""), - "last": case_data.get("new_last_name", ""), - "full": (f"{case_data.get('new_first_name', '')} {case_data.get('new_last_name', '')}").strip(), - } - } - - # Add case metadata - payload["metadata"] = { - "created_via": "illinois_efile_system", - "case_classification": { - "court": case_data.get("court"), - "category": case_data.get("case_category"), - "case_type": case_data.get("case_type"), - "filing_type": case_data.get("filing_type"), - "document_type": case_data.get("document_type"), - }, - } - - return payload - - -def determine_party_type_for_new_case(case_data): - """ - Determine the appropriate party type for a new case. - This fetches actual party type codes from the API. - """ - court_code = case_data.get("court") - case_type_code = case_data.get("case_type") - case_type = case_data.get("case_type", "").lower() - jurisdiction = case_data.get("jurisdiction") - - if not court_code or not case_type_code: - logger.warning("Missing court or case_type for party type determination") - return "PET" # Default fallback code for petitioner - - # Determine target party type name based on case type - target_party_name = None - - if "name change" in case_type: - target_party_name = "petitioner" - elif "civil" in case_type: - target_party_name = "plaintiff" - elif "family" in case_type: - target_party_name = "petitioner" - elif "probate" in case_type: - target_party_name = "petitioner" - else: - target_party_name = "petitioner" - - # Get the actual party type code from API - party_code = get_party_type_code_from_api( - court_code, case_type_code, jurisdiction, target_party_name=target_party_name - ) - - # Fallback codes if API call fails - if not party_code: - if target_party_name == "plaintiff": - return "PLA" - elif target_party_name == "petitioner": - return "PET" - else: - return "PET" - - return party_code - - -def determine_party_type_for_existing_case(case_data): - """ - Determine the appropriate party type when responding to an existing case. - This fetches actual party type codes from the API. - """ - court_code = case_data.get("court") - case_type_code = case_data.get("case_type") - case_type = case_data.get("case_type", "").lower() - filing_type = case_data.get("filing_type", "").lower() - jurisdiction = case_data.get("jurisdiction") - - if not court_code or not case_type_code: - logger.warning("Missing court or case_type for party type determination") - return "DEF" # Default fallback code - - # Determine target party type name based on case and filing type - target_party_name = None - - if "criminal" in case_type: - target_party_name = "defendant" - elif "civil" in case_type or "family" in case_type: - if "answer" in filing_type or "response" in filing_type: - target_party_name = "respondent" - else: - target_party_name = "defendant" - elif "probate" in case_type: - target_party_name = "interested party" - else: - target_party_name = "defendant" - - # Get the actual party type code from API - party_code = get_party_type_code_from_api( - court_code, case_type_code, jurisdiction, target_party_name=target_party_name - ) - - # Fallback codes if API call fails - if not party_code: - if target_party_name == "defendant": - return "DEF" - elif target_party_name == "respondent": - return "RES" - elif target_party_name == "interested party": - return "INT" - else: - return "DEF" - - return party_code - - # Individual view functions for URL mapping get_filings = FilingAPIViews.get_filings create_filing = FilingAPIViews.create_filing diff --git a/efile_app/efile/api/s3_upload.py b/efile_app/efile/api/s3_upload.py index 5c12032..4da5336 100644 --- a/efile_app/efile/api/s3_upload.py +++ b/efile_app/efile/api/s3_upload.py @@ -5,7 +5,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from ..utils.s3_upload_handler import s3_handler # noqa: F401 +from ..utils.s3_upload_handler import S3UploadHandler logger = logging.getLogger(__name__) @@ -15,10 +15,8 @@ def test_s3_connection(request): """Test S3 connection and bucket access.""" try: - # Reinitialize the global handler to pick up the corrected credentials - global s3_handler - from ..utils.s3_upload_handler import S3UploadHandler - + # A fresh handler per request, so a credential change takes effect + # without a restart. s3_handler = S3UploadHandler() # Test S3 connection @@ -64,10 +62,6 @@ def simple_s3_upload(request): if not uploaded_files: return JsonResponse({"success": False, "error": "No documents provided."}, status=400) - # Reinitialize S3 handler - global s3_handler - from ..utils.s3_upload_handler import S3UploadHandler - s3_handler = S3UploadHandler() if not s3_handler._ensure_initialized(): diff --git a/efile_app/efile/api/suffolk_api_views.py b/efile_app/efile/api/suffolk_api_views.py index a5b9efd..de75c5a 100644 --- a/efile_app/efile/api/suffolk_api_views.py +++ b/efile_app/efile/api/suffolk_api_views.py @@ -12,6 +12,7 @@ from django.views.decorators.http import require_http_methods from requests.exceptions import RequestException +from efile.utils.case_data_utils import get_case_data, update_case_data from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request from efile.utils.proxy_connection import get_headers @@ -292,17 +293,43 @@ def get_party_types_from_suffolk_api(request): f"{party_types[0].get('name', 'Unknown')} ({selected_party_type})" ) - # Save to session - case_data = request.session.get("case_data", {}) - case_data["determined_party_type"] = selected_party_type - case_data["party_type"] = selected_party_type - case_data["petitioner_party_type"] = selected_party_type - case_data["available_party_types"] = party_types - case_data["existing_case"] = existing_case # Save existing case status - request.session["case_data"] = case_data - request.session.modified = True - - logger.debug(f"Saved party type to session: {selected_party_type}") + # Seed the draft's party type, but never overwrite one it already + # has. Everything above is a *guess* from the case type's name + # ("civil" -> plaintiff), and this endpoint is a GET called on + # page load -- including from review, long after the filer chose. + # Overwriting there silently replaced a filer who had picked + # Defendant/Respondent with the guessed Plaintiff/Petitioner, + # leaving both sides the same party type and the filing rejected + # by the court for a required party that no longer had anyone in + # it. The guess is a default for an empty draft, not an answer. + stored_case_data = get_case_data(request, jurisdiction) or {} + stored_party_type = next( + ( + stored_case_data.get(key) + for key in ("determined_party_type", "party_type", "petitioner_party_type") + if stored_case_data.get(key) + ), + "", + ) + + if stored_party_type: + # Answer with what the draft holds, so the page cannot show a + # different party type from the one that will be filed. + selected_party_type = stored_party_type + update_case_data(request, {"existing_case": existing_case}, jurisdiction) + logger.debug("Kept the party type already on the draft: %s", stored_party_type) + else: + update_case_data( + request, + { + "determined_party_type": selected_party_type, + "party_type": selected_party_type, + "petitioner_party_type": selected_party_type, + "existing_case": existing_case, + }, + jurisdiction, + ) + logger.debug(f"Saved party type to draft: {selected_party_type}") if only_required: filtered_party_types = [p for p in party_types if p.get("isrequired", False)] diff --git a/efile_app/efile/apps.py b/efile_app/efile/apps.py index 4131156..bb25fcb 100644 --- a/efile_app/efile/apps.py +++ b/efile_app/efile/apps.py @@ -4,3 +4,7 @@ class EfileConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "efile" + + def ready(self): + # Registers the checks in efile/checks.py by importing them. + from efile import checks # noqa: F401 diff --git a/efile_app/efile/checks.py b/efile_app/efile/checks.py new file mode 100644 index 0000000..8ac7c0d --- /dev/null +++ b/efile_app/efile/checks.py @@ -0,0 +1,52 @@ +"""Startup system checks. + +These run on every management command -- including the ``manage.py migrate`` that +fly.toml uses as its release command -- so a misconfiguration fails the deploy +instead of waiting to be discovered by a filing. +""" + +from django.conf import settings +from django.core.checks import Error, Warning, register + + +@register() +def efsp_stand_in_document_is_development_only(app_configs, **kwargs): + """EFSP_TEST_DOCUMENT_URL must never be active outside local development. + + When set, every document in a filing payload is replaced by a stand-in PDF + before the payload reaches the EFSP proxy. That is indispensable locally, + where the proxy cannot fetch a LocalStack URL, and unacceptable anywhere a + filing might reach a real court. + + Reported as an Error (which blocks the command) rather than a Warning: the + correct response to finding this in production is to stop, not to continue + and hope the request-time guard in efsp_payload catches it. + """ + stand_in_url = getattr(settings, "EFSP_TEST_DOCUMENT_URL", "") + if not stand_in_url: + return [] + + if not settings.DEBUG: + return [ + Error( + "EFSP_TEST_DOCUMENT_URL is set while DEBUG is False.", + hint=( + "This replaces every filed document with a stand-in PDF. It is a " + "local-development affordance only. Unset EFSP_TEST_DOCUMENT_URL, and " + "confirm DJANGO_SETTINGS_MODULE points at efile.settings_staging or " + "efile.settings_prod rather than falling back to efile.settings (dev)." + ), + id="efile.E001", + ) + ] + + return [ + Warning( + f"EFSP_TEST_DOCUMENT_URL is active: filings will send {stand_in_url} in place of every uploaded document.", + hint=( + "Fee quotes and submissions from this process do not reflect real " + "documents. Unset it to send real S3 URLs to the EFSP proxy." + ), + id="efile.W001", + ) + ] diff --git a/efile_app/efile/migrations/0001_initial.py b/efile_app/efile/migrations/0001_initial.py new file mode 100644 index 0000000..07c31ed --- /dev/null +++ b/efile_app/efile/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# Generated by Django 5.2.5 on 2026-06-29 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Baseline the pre-existing custom user table. + + Existing environments should apply this with ``migrate --fake-initial``; + fresh databases create the table normally. + """ + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), + ), + ("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")), + ("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")), + ("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text=( + "Designates whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + verbose_name="active", + ), + ), + ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")), + ("tyler_jurisdiction", models.CharField(max_length=20)), + ("tyler_user_id", models.CharField(blank=True, max_length=100, null=True)), + ("email_updates", models.BooleanField(default=False)), + ("text_updates", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text=( + "The groups this user belongs to. A user will get all permissions granted to each of " + "their groups." + ), + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "User Profile", + "verbose_name_plural": "User Profiles", + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/efile_app/efile/migrations/0002_filing_drafts.py b/efile_app/efile/migrations/0002_filing_drafts.py new file mode 100644 index 0000000..8bdacfd --- /dev/null +++ b/efile_app/efile/migrations/0002_filing_drafts.py @@ -0,0 +1,184 @@ +# Generated by Django 5.2.5 on 2026-06-29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FilingDraft", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("jurisdiction", models.CharField(db_index=True, max_length=40)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitting", "Submitting"), + ("submitted", "Submitted"), + ("error", "Error"), + ("abandoned", "Abandoned"), + ], + db_index=True, + default="draft", + max_length=20, + ), + ), + ( + "current_step", + models.CharField( + choices=[ + ("options", "Options"), + ("upload_first", "Upload lead document"), + ("case_information", "Case information"), + ("documents", "Documents"), + ("payment", "Payment"), + ("review", "Review"), + ("confirmation", "Confirmation"), + ], + default="options", + max_length=64, + ), + ), + ("existing_case", models.CharField(blank=True, max_length=20)), + ("court_code", models.CharField(blank=True, max_length=100)), + ("court_name", models.CharField(blank=True, max_length=255)), + ("case_category_code", models.CharField(blank=True, max_length=100)), + ("case_category_name", models.CharField(blank=True, max_length=255)), + ("case_type_code", models.CharField(blank=True, max_length=100)), + ("case_type_name", models.CharField(blank=True, max_length=255)), + ("case_subtype_code", models.CharField(blank=True, max_length=100)), + ("case_subtype_name", models.CharField(blank=True, max_length=255)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("previous_case_id", models.CharField(blank=True, max_length=255)), + ("docket_number", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_id", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_name", models.CharField(blank=True, max_length=255)), + ("name_change_reason", models.TextField(blank=True)), + ("optional_services", models.JSONField(blank=True, default=list)), + ("extracted_guesses", models.JSONField(blank=True, default=dict)), + ("supplemental_fields", models.JSONField(blank=True, default=dict)), + ("submission_response", models.JSONField(blank=True, default=dict)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="filing_drafts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + "indexes": [ + models.Index(fields=["user", "status"], name="draft_user_status_idx"), + models.Index(fields=["jurisdiction", "status"], name="draft_jurisdiction_status_idx"), + models.Index(fields=["status", "updated_at"], name="draft_status_updated_idx"), + ], + }, + ), + migrations.CreateModel( + name="FilingDocument", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "role", + models.CharField( + choices=[("lead", "Lead document"), ("supporting", "Supporting document")], + max_length=20, + ), + ), + ("sort_order", models.PositiveIntegerField(default=0)), + ("name", models.CharField(blank=True, max_length=255)), + ("original_filename", models.CharField(blank=True, max_length=255)), + ("size", models.PositiveBigIntegerField(blank=True, null=True)), + ("content_type", models.CharField(blank=True, max_length=255)), + ("s3_key", models.CharField(blank=True, max_length=1024)), + ("public_url", models.URLField(blank=True, max_length=2048)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("filing_component_code", models.CharField(blank=True, max_length=100)), + ("filing_component_name", models.CharField(blank=True, max_length=255)), + ("courtesy_copy_email", models.EmailField(blank=True, max_length=254)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="documents", + to="efile.filingdraft", + ), + ), + ], + options={ + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="FilingParty", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(max_length=50)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("party_type", models.CharField(blank=True, max_length=100)), + ("external_party_id", models.CharField(blank=True, max_length=255)), + ("first_name", models.CharField(blank=True, max_length=100)), + ("middle_name", models.CharField(blank=True, max_length=100)), + ("last_name", models.CharField(blank=True, max_length=100)), + ("suffix", models.CharField(blank=True, max_length=50)), + ("organization_name", models.CharField(blank=True, max_length=255)), + ("email", models.EmailField(blank=True, max_length=254)), + ("phone", models.CharField(blank=True, max_length=50)), + ("address_line_1", models.CharField(blank=True, max_length=255)), + ("address_line_2", models.CharField(blank=True, max_length=255)), + ("city", models.CharField(blank=True, max_length=100)), + ("state", models.CharField(blank=True, max_length=50)), + ("zip_code", models.CharField(blank=True, max_length=20)), + ("country", models.CharField(blank=True, default="US", max_length=2)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="parties", + to="efile.filingdraft", + ), + ), + ], + options={ + "verbose_name_plural": "Filing parties", + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.AddConstraint( + model_name="filingdocument", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), + name="unique_document_order_per_draft_role", + ), + ), + migrations.AddConstraint( + model_name="filingparty", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), + name="unique_party_order_per_draft_role", + ), + ), + ] diff --git a/efile_app/efile/migrations/__init__.py b/efile_app/efile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 11a6518..0b37f82 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -1,6 +1,10 @@ # models.py - Optional extension to store additional user information +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.utils import timezone + +from efile.workflow import WorkflowStepKey, get_workflow_step_choices class UserProfile(AbstractUser): @@ -28,6 +32,166 @@ class Meta: verbose_name_plural = "User Profiles" -# Don't forget to run migrations if you add this model: -# python manage.py makemigrations -# python manage.py migrate --run-syncdb +class FilingDraft(models.Model): + """Durable aggregate for a single in-progress or submitted court filing.""" + + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SUBMITTING = "submitting", "Submitting" + SUBMITTED = "submitted", "Submitted" + ERROR = "error", "Error" + ABANDONED = "abandoned", "Abandoned" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="filing_drafts", + ) + jurisdiction = models.CharField(max_length=40, db_index=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True) + current_step = models.CharField( + max_length=64, + choices=get_workflow_step_choices(), + default=WorkflowStepKey.OPTIONS, + ) + + existing_case = models.CharField(max_length=20, blank=True) + court_code = models.CharField(max_length=100, blank=True) + court_name = models.CharField(max_length=255, blank=True) + case_category_code = models.CharField(max_length=100, blank=True) + case_category_name = models.CharField(max_length=255, blank=True) + case_type_code = models.CharField(max_length=100, blank=True) + case_type_name = models.CharField(max_length=255, blank=True) + case_subtype_code = models.CharField(max_length=100, blank=True) + case_subtype_name = models.CharField(max_length=255, blank=True) + filing_type_code = models.CharField(max_length=100, blank=True) + filing_type_name = models.CharField(max_length=255, blank=True) + document_type_code = models.CharField(max_length=100, blank=True) + document_type_name = models.CharField(max_length=255, blank=True) + + previous_case_id = models.CharField(max_length=255, blank=True) + docket_number = models.CharField(max_length=255, blank=True) + + selected_payment_account_id = models.CharField(max_length=255, blank=True) + selected_payment_account_name = models.CharField(max_length=255, blank=True) + + name_change_reason = models.TextField(blank=True) + + optional_services = models.JSONField(default=list, blank=True) + extracted_guesses = models.JSONField(default=dict, blank=True) + # Area-of-law questionnaire answers (e.g. divorce children questions). These are + # driven by the per-state/case-type config, not a fixed schema, so they live in a + # 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) + submission_response = models.JSONField(default=dict, blank=True) + + submitted_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + indexes = [ + models.Index(fields=["user", "status"], name="draft_user_status_idx"), + models.Index(fields=["jurisdiction", "status"], name="draft_jurisdiction_status_idx"), + models.Index(fields=["status", "updated_at"], name="draft_status_updated_idx"), + ] + + def __str__(self): + return f"{self.get_status_display()} filing draft #{self.pk} ({self.jurisdiction})" + + def mark_submitted(self, response_data): + self.status = self.Status.SUBMITTED + self.current_step = WorkflowStepKey.CONFIRMATION + self.submission_response = response_data or {} + self.submitted_at = timezone.now() + self.save(update_fields=["status", "current_step", "submission_response", "submitted_at", "updated_at"]) + + def mark_error(self, response_data): + self.status = self.Status.ERROR + self.submission_response = response_data or {} + self.save(update_fields=["status", "submission_response", "updated_at"]) + + +class FilingDocument(models.Model): + """Uploaded document that belongs to a filing draft.""" + + class Role(models.TextChoices): + LEAD = "lead", "Lead document" + SUPPORTING = "supporting", "Supporting document" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE, related_name="documents") + role = models.CharField(max_length=20, choices=Role.choices) + sort_order = models.PositiveIntegerField(default=0) + + name = models.CharField(max_length=255, blank=True) + original_filename = models.CharField(max_length=255, blank=True) + size = models.PositiveBigIntegerField(blank=True, null=True) + content_type = models.CharField(max_length=255, blank=True) + s3_key = models.CharField(max_length=1024, blank=True) + public_url = models.URLField(max_length=2048, blank=True) + + filing_type_code = models.CharField(max_length=100, blank=True) + filing_type_name = models.CharField(max_length=255, blank=True) + document_type_code = models.CharField(max_length=100, blank=True) + document_type_name = models.CharField(max_length=255, blank=True) + filing_component_code = models.CharField(max_length=100, blank=True) + filing_component_name = models.CharField(max_length=255, blank=True) + + courtesy_copy_email = models.EmailField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["role", "sort_order", "created_at"] + constraints = [ + models.UniqueConstraint( + fields=["draft", "role", "sort_order"], name="unique_document_order_per_draft_role" + ), + ] + + def __str__(self): + return self.name or f"{self.get_role_display()} for draft #{self.draft_id}" + + +class FilingParty(models.Model): + """Person or organization associated with a filing draft.""" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE, related_name="parties") + role = models.CharField(max_length=50) + sort_order = models.PositiveIntegerField(default=0) + + party_type = models.CharField(max_length=100, blank=True) + external_party_id = models.CharField(max_length=255, blank=True) + + first_name = models.CharField(max_length=100, blank=True) + middle_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) + suffix = models.CharField(max_length=50, blank=True) + organization_name = models.CharField(max_length=255, blank=True) + + email = models.EmailField(blank=True) + phone = models.CharField(max_length=50, blank=True) + + address_line_1 = models.CharField(max_length=255, blank=True) + address_line_2 = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=50, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + country = models.CharField(max_length=2, default="US", blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["role", "sort_order", "created_at"] + constraints = [ + models.UniqueConstraint(fields=["draft", "role", "sort_order"], name="unique_party_order_per_draft_role"), + ] + verbose_name_plural = "Filing parties" + + def __str__(self): + display_name = " ".join(part for part in [self.first_name, self.middle_name, self.last_name] if part) + return display_name or self.organization_name or f"{self.role} for draft #{self.draft_id}" diff --git a/efile_app/efile/services/__init__.py b/efile_app/efile/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/efile_app/efile/services/current_drafts.py b/efile_app/efile/services/current_drafts.py new file mode 100644 index 0000000..2e9d756 --- /dev/null +++ b/efile_app/efile/services/current_drafts.py @@ -0,0 +1,112 @@ +"""Select the durable filing draft associated with the current request.""" + +from __future__ import annotations + +from django.db import transaction + +from efile.models import FilingDraft +from efile.services.drafts import CURRENT_DRAFT_STATUSES, create_draft, get_active_draft, set_current_step +from efile.workflow import WorkflowStepKey + +CURRENT_DRAFT_SESSION_KEY = "filing_draft_id" + + +def _authenticated_user(request): + user = getattr(request, "user", None) + return user if getattr(user, "is_authenticated", False) else None + + +def attach_current_draft(request, draft: FilingDraft) -> None: + """Remember which durable draft this browser is editing.""" + + request.session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + request.session["jurisdiction"] = draft.jurisdiction + request.session.modified = True + + +def clear_current_draft(request) -> None: + if CURRENT_DRAFT_SESSION_KEY in request.session: + del request.session[CURRENT_DRAFT_SESSION_KEY] + request.session.modified = True + + +def get_current_draft( + request, + *, + jurisdiction: str | None = None, + resume_latest: bool = True, +) -> FilingDraft | None: + """Resolve the current user's draft without trusting a bare session ID. + + The session only stores a pointer. Ownership, active status, and (when + supplied) jurisdiction are enforced on every lookup. + """ + + user = _authenticated_user(request) + if user is None: + clear_current_draft(request) + return None + + draft_id = request.session.get(CURRENT_DRAFT_SESSION_KEY) + if draft_id is not None: + try: + draft_id = int(draft_id) + except (TypeError, ValueError): + clear_current_draft(request) + draft_id = None + + if draft_id is not None: + # The pointed-at draft may be mid-submission (SUBMITTING); it is still the + # user's current draft, so resolve it even though resume/listings would not. + draft = get_active_draft( + user=user, + draft_id=draft_id, + jurisdiction=jurisdiction, + statuses=CURRENT_DRAFT_STATUSES, + ) + if draft is not None: + return draft + clear_current_draft(request) + + if not resume_latest: + return None + + draft = get_active_draft(user=user, jurisdiction=jurisdiction) + if draft is not None: + attach_current_draft(request, draft) + return draft + + +@transaction.atomic +def create_current_draft( + request, + jurisdiction: str, + *, + current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, +) -> FilingDraft: + draft = create_draft( + user=_authenticated_user(request), + jurisdiction=jurisdiction, + current_step=current_step, + ) + attach_current_draft(request, draft) + return draft + + +@transaction.atomic +def ensure_current_draft( + request, + jurisdiction: str, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + draft = get_current_draft(request, jurisdiction=jurisdiction) + if draft is None: + return create_current_draft( + request, + jurisdiction, + current_step=current_step or WorkflowStepKey.OPTIONS, + ) + if current_step is not None: + set_current_step(draft, current_step) + return draft diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py new file mode 100644 index 0000000..0967151 --- /dev/null +++ b/efile_app/efile/services/drafts.py @@ -0,0 +1,503 @@ +"""Operations on the durable filing draft aggregate. + +This module deliberately has no dependency on HTTP requests or sessions. Request/ +session selection lives in ``current_drafts``; the wire (de)serialization between +the browser's flat case_data/upload_data blobs and the typed model lives here. +""" + +from __future__ import annotations + +from typing import Any + +from django.db import transaction +from django.db.models import QuerySet + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.workflow import WorkflowStepKey + +ACTIVE_DRAFT_STATUSES = (FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR) +# A draft mid-submission is still the user's current draft (so the submit flow can +# read its own data), but it is not offered for resume in listings. +CURRENT_DRAFT_STATUSES = (*ACTIVE_DRAFT_STATUSES, FilingDraft.Status.SUBMITTING) + + +def active_drafts_for(user, *, jurisdiction: str | None = None) -> QuerySet[FilingDraft]: + """Return active drafts owned by ``user``, newest first.""" + + drafts = FilingDraft.objects.filter(user=user, status__in=ACTIVE_DRAFT_STATUSES) + if jurisdiction is not None: + drafts = drafts.filter(jurisdiction=jurisdiction) + return drafts.order_by("-updated_at") + + +def get_active_draft( + *, + user, + draft_id: int | str | None = None, + jurisdiction: str | None = None, + statuses: tuple[str, ...] = ACTIVE_DRAFT_STATUSES, +) -> FilingDraft | None: + """Get an owned draft by ID, or the user's most recent one, within ``statuses``.""" + + drafts = FilingDraft.objects.filter(user=user, status__in=statuses) + if jurisdiction is not None: + drafts = drafts.filter(jurisdiction=jurisdiction) + drafts = drafts.order_by("-updated_at") + if draft_id is not None: + return drafts.filter(pk=draft_id).first() + return drafts.first() + + +@transaction.atomic +def create_draft( + *, + user, + jurisdiction: str, + current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, +) -> FilingDraft: + """Create a durable draft owned by an authenticated user.""" + + if not getattr(user, "is_authenticated", False): + raise ValueError("A filing draft must have an authenticated owner") + if not jurisdiction: + raise ValueError("A filing draft must have a jurisdiction") + + return FilingDraft.objects.create( + user=user, + jurisdiction=jurisdiction, + current_step=str(current_step), + ) + + +def set_current_step(draft: FilingDraft, current_step: WorkflowStepKey | str) -> FilingDraft: + """Advance or rewind a draft's current UI step when it changed.""" + + step = str(current_step) + if draft.current_step != step: + draft.current_step = step + draft.save(update_fields=["current_step", "updated_at"]) + return draft + + +# --- Wire (de)serialization ------------------------------------------------- +# +# The browser speaks a flat "case_data" / "upload_data" JSON blob. These maps are +# the *entire* contract: any key not listed here is deliberately not persisted. +# Adding a field a screen needs to keep means adding a typed column/party field +# and an entry here -- there is no catch-all blob. + +_MISSING = object() + +# Draft scalar column -> the wire keys it may arrive under (first present wins). +_DRAFT_FIELD_SOURCES: dict[str, tuple[str, ...]] = { + "court_code": ("court", "court_code"), + "court_name": ("court_name",), + # The dropdown flow sends the bare name ("case_category"); the existing-case + # lookup sends the explicit "*_code" form. Accept both. + "case_category_code": ("case_category", "case_category_code"), + "case_category_name": ("case_category_name",), + "case_type_code": ("case_type", "case_type_code"), + "case_type_name": ("case_type_name",), + "case_subtype_code": ("case_subtype", "case_subtype_code"), + "case_subtype_name": ("case_subtype_name",), + "filing_type_code": ("filing_type", "filing_type_id"), + "filing_type_name": ("filing_type_name",), + "document_type_code": ("document_type",), + "document_type_name": ("document_type_name",), + "existing_case": ("existing_case",), + "previous_case_id": ("case_tracking_id", "previous_case_id"), + "docket_number": ("case_docket_id", "case_number", "docket_number"), + "selected_payment_account_id": ("selected_payment_account", "payment_account_id"), + "selected_payment_account_name": ("selected_payment_account_name",), + "name_change_reason": ("reason_for_name_change", "reason_for_change"), +} + +# FilingParty role -> {model field: wire key}. Petitioner party_type is special +# (three legacy aliases for one value) and handled separately. +_PARTY_SPECS: dict[str, dict[str, str]] = { + "petitioner": { + "first_name": "petitioner_first_name", + "last_name": "petitioner_last_name", + "address_line_1": "petitioner_address", + "email": "petitioner_email", + "phone": "petitioner_phone", + }, + "new_name": { + "first_name": "new_first_name", + "middle_name": "new_middle_name", + "last_name": "new_last_name", + "suffix": "new_suffix", + "party_type": "new_name_party_type", + }, + "respondent": { + "first_name": "respondent_first_name", + "middle_name": "respondent_middle_name", + "last_name": "respondent_last_name", + "suffix": "respondent_suffix", + "party_type": "respondent_name_party_type", + }, + "other": { + "first_name": "other_first_name", + "last_name": "other_last_name", + "party_type": "other_party_type", + "address_line_1": "other_address_line_1", + "address_line_2": "other_address_line_2", + "city": "other_address_city", + "state": "other_address_state", + "zip_code": "other_address_zip", + "email": "other_email", + "phone": "other_phone_number", + }, +} + +_PETITIONER_PARTY_TYPE_KEYS = ("determined_party_type", "party_type", "petitioner_party_type") + +# Area-of-law questionnaire fields that persist in ``supplemental_fields`` instead +# of a dedicated column. Keep this allowlist aligned with fields currently rendered +# from the state configuration. Values are stored as received rather than being +# string-coerced like the typed columns. +_SUPPLEMENTAL_FIELDS = ("has_children", "child_count") + + +def _first_present(data: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key in data: + return data[key] + return _MISSING + + +def _as_str(value: Any) -> str: + return "" if value in (None, "") else str(value) + + +def _put(data: dict[str, Any], key: str, value: Any) -> None: + """Add ``key`` to the wire blob only when it carries a real value.""" + + if value not in (None, "", [], {}): + data[key] = value + + +@transaction.atomic +def write_case_data( + draft: FilingDraft, + case_data: dict[str, Any] | None, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + """Persist a (possibly partial) case_data blob onto the draft. + + Only keys present in ``case_data`` are written; omitted fields are left as-is + so per-screen partial saves don't clobber earlier steps. + """ + + data = dict(case_data or {}) + update_fields: list[str] = [] + + for field, sources in _DRAFT_FIELD_SOURCES.items(): + value = _first_present(data, sources) + if value is _MISSING: + continue + value = _as_str(value) + if getattr(draft, field) != value: + setattr(draft, field, value) + update_fields.append(field) + + if "optional_services" in data: + services = data.get("optional_services") or [] + if draft.optional_services != services: + draft.optional_services = services + update_fields.append("optional_services") + + supplemental_updates = {key: data[key] for key in _SUPPLEMENTAL_FIELDS if key in data} + if supplemental_updates: + merged = {**draft.supplemental_fields, **supplemental_updates} + if merged != draft.supplemental_fields: + draft.supplemental_fields = merged + update_fields.append("supplemental_fields") + + if current_step is not None and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + + if update_fields: + draft.save(update_fields=sorted({*update_fields, "updated_at"})) + + _write_parties(draft, data) + return draft + + +def _write_parties(draft: FilingDraft, data: dict[str, Any]) -> None: + for role, spec in _PARTY_SPECS.items(): + values = {model_field: _as_str(data[wire_key]) for model_field, wire_key in spec.items() if wire_key in data} + if role == "petitioner": + party_type = _first_present(data, _PETITIONER_PARTY_TYPE_KEYS) + if party_type is not _MISSING: + values["party_type"] = _as_str(party_type) + if values: + FilingParty.objects.update_or_create(draft=draft, role=role, sort_order=0, defaults=values) + + +def read_case_data(draft: FilingDraft | None) -> dict[str, Any]: + """Serialize the draft back into the flat case_data blob the browser reads.""" + + if draft is None: + return {} + + data: dict[str, Any] = {} + _put(data, "jurisdiction", draft.jurisdiction) + _put(data, "jurisdiction_id", draft.jurisdiction) + _put(data, "existing_case", draft.existing_case) + _put(data, "court", draft.court_code) + _put(data, "court_name", draft.court_name) + _put(data, "case_category", draft.case_category_code) + _put(data, "case_category_name", draft.case_category_name) + _put(data, "case_type", draft.case_type_code) + _put(data, "case_type_name", draft.case_type_name) + _put(data, "case_subtype", draft.case_subtype_code) + _put(data, "case_subtype_name", draft.case_subtype_name) + _put(data, "filing_type", draft.filing_type_code) + _put(data, "filing_type_id", draft.filing_type_code) + _put(data, "filing_type_name", draft.filing_type_name) + _put(data, "document_type", draft.document_type_code) + _put(data, "document_type_name", draft.document_type_name) + _put(data, "previous_case_id", draft.previous_case_id) + _put(data, "docket_number", draft.docket_number) + _put(data, "selected_payment_account", draft.selected_payment_account_id) + _put(data, "selected_payment_account_name", draft.selected_payment_account_name) + _put(data, "optional_services", list(draft.optional_services or [])) + _put(data, "reason_for_name_change", draft.name_change_reason) + _put(data, "reason_for_change", draft.name_change_reason) + + for party in FilingParty.objects.filter(draft=draft): + spec = _PARTY_SPECS.get(party.role) + if spec is None: + continue + for model_field, wire_key in spec.items(): + _put(data, wire_key, getattr(party, model_field)) + if party.role == "petitioner" and party.party_type: + for key in _PETITIONER_PARTY_TYPE_KEYS: + _put(data, key, party.party_type) + + # Supplemental answers are emitted as stored (a False/0 answer is meaningful). + for key, value in (draft.supplemental_fields or {}).items(): + data[key] = value + + return data + + +def _lead_config(data: dict[str, Any]) -> dict[str, Any]: + config = { + "filing_type": data.get("lead_filing_type"), + "filing_type_name": data.get("lead_filing_type_name"), + "document_type": data.get("lead_document_type"), + "document_type_name": data.get("lead_document_type_name"), + "filing_component": data.get("lead_filing_component"), + "filing_component_name": data.get("lead_filing_component_name"), + "cc_email": data.get("lead_cc_email"), + } + return {key: value for key, value in config.items() if value not in (None, "")} + + +def _positive_int(value: Any) -> int | None: + try: + number = int(value) + except (TypeError, ValueError): + return None + return number if number >= 0 else None + + +def _apply_document(doc: FilingDocument, file_obj: dict[str, Any], config: dict[str, Any]) -> None: + if "name" in file_obj: + doc.name = _as_str(file_obj.get("name")) + if not doc.original_filename: + doc.original_filename = doc.name + if "url" in file_obj: + doc.public_url = _as_str(file_obj.get("url")) + if "s3_key" in file_obj: + doc.s3_key = _as_str(file_obj.get("s3_key")) + if "type" in file_obj or "content_type" in file_obj: + doc.content_type = _as_str(file_obj.get("type") or file_obj.get("content_type")) + if "size" in file_obj: + doc.size = _positive_int(file_obj.get("size")) + + config_fields = { + "filing_type_code": "filing_type", + "filing_type_name": "filing_type_name", + "document_type_code": "document_type", + "document_type_name": "document_type_name", + "filing_component_code": "filing_component", + "filing_component_name": "filing_component_name", + "courtesy_copy_email": "cc_email", + } + for model_field, config_key in config_fields.items(): + if config_key in config: + value = config.get(config_key) + if isinstance(value, dict): + value = value.get("id") or value.get("code") or "" + setattr(doc, model_field, _as_str(value)) + + # Older upload clients stored the selected component on the file object as + # {id, name}, while the durable draft config was empty. Preserve that code + # so the payment payload never falls back to the literal "supporting". + if not doc.filing_component_code and file_obj.get("filing_component"): + value = file_obj["filing_component"] + if isinstance(value, dict): + value = value.get("id") or value.get("code") or "" + doc.filing_component_code = _as_str(value) + + +def _upsert_document( + draft: FilingDraft, + role: str, + sort_order: int, + file_obj: dict[str, Any], + config: dict[str, Any], +) -> None: + doc, _created = FilingDocument.objects.get_or_create(draft=draft, role=role, sort_order=sort_order) + _apply_document(doc, file_obj, config) + doc.save() + + +@transaction.atomic +def write_upload_data( + draft: FilingDraft, + upload_data: dict[str, Any] | None, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + """Persist a (possibly partial) upload_data blob into FilingDocument rows.""" + + data = dict(upload_data or {}) + update_fields: list[str] = [] + + if "guesses" in data: + guesses = data.get("guesses") or {} + if draft.extracted_guesses != guesses: + draft.extracted_guesses = guesses + update_fields.append("extracted_guesses") + + files = data.get("files") or {} + lead_config = _lead_config(data) + + if "lead" in files: + _upsert_document(draft, FilingDocument.Role.LEAD, 0, files.get("lead") or {}, lead_config) + elif lead_config: + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + if lead is not None: + _apply_document(lead, {}, lead_config) + lead.save() + + if "supporting" in files: + supporting_files = files.get("supporting") or [] + supporting_configs = data.get("supporting_documents") or [] + FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).delete() + for index, file_obj in enumerate(supporting_files): + config = supporting_configs[index] if index < len(supporting_configs) else {} + _upsert_document(draft, FilingDocument.Role.SUPPORTING, index, file_obj or {}, config or {}) + + if current_step is not None and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + + if update_fields: + draft.save(update_fields=sorted({*update_fields, "updated_at"})) + return draft + + +def _document_file(doc: FilingDocument) -> dict[str, Any]: + file_obj: dict[str, Any] = {} + _put(file_obj, "name", doc.name) + _put(file_obj, "url", doc.public_url) + _put(file_obj, "s3_key", doc.s3_key) + _put(file_obj, "type", doc.content_type) + _put(file_obj, "size", doc.size) + return file_obj + + +def _document_config(doc: FilingDocument) -> dict[str, Any]: + config: dict[str, Any] = {} + _put(config, "filing_type", doc.filing_type_code) + _put(config, "filing_type_name", doc.filing_type_name) + _put(config, "document_type", doc.document_type_code) + _put(config, "document_type_name", doc.document_type_name) + _put(config, "filing_component", doc.filing_component_code) + _put(config, "filing_component_name", doc.filing_component_name) + _put(config, "cc_email", doc.courtesy_copy_email) + return config + + +def read_upload_data(draft: FilingDraft | None) -> dict[str, Any]: + """Reconstruct the upload_data blob the browser reads from document rows.""" + + if draft is None: + return {} + + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + supporting = list( + FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).order_by("sort_order") + ) + + files: dict[str, Any] = {} + if lead is not None: + files["lead"] = _document_file(lead) + supporting_files = [_document_file(doc) for doc in supporting] + if supporting_files: + files["supporting"] = supporting_files + + data: dict[str, Any] = {} + if files: + data["files"] = files + _put(data, "guesses", draft.extracted_guesses) + + if lead is not None: + _put(data, "lead_filing_type", lead.filing_type_code) + _put(data, "lead_filing_type_name", lead.filing_type_name) + _put(data, "lead_document_type", lead.document_type_code) + _put(data, "lead_document_type_name", lead.document_type_name) + _put(data, "lead_filing_component", lead.filing_component_code) + _put(data, "lead_filing_component_name", lead.filing_component_name) + _put(data, "lead_cc_email", lead.courtesy_copy_email) + + supporting_configs = [_document_config(doc) for doc in supporting] + if any(supporting_configs): + data["supporting_documents"] = supporting_configs + + return data + + +def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: + """Return the stable, JSON-safe representation exposed to the UI.""" + + if draft is None: + return None + return { + "id": draft.pk, + "jurisdiction": draft.jurisdiction, + "status": draft.status, + "current_step": draft.current_step, + "existing_case": draft.existing_case, + "court_code": draft.court_code, + "court_name": draft.court_name, + "case_category_code": draft.case_category_code, + "case_category_name": draft.case_category_name, + "case_type_code": draft.case_type_code, + "case_type_name": draft.case_type_name, + "case_subtype_code": draft.case_subtype_code, + "case_subtype_name": draft.case_subtype_name, + "filing_type_code": draft.filing_type_code, + "filing_type_name": draft.filing_type_name, + "document_type_code": draft.document_type_code, + "document_type_name": draft.document_type_name, + "previous_case_id": draft.previous_case_id, + "docket_number": draft.docket_number, + "selected_payment_account_id": draft.selected_payment_account_id, + "selected_payment_account_name": draft.selected_payment_account_name, + "optional_services": draft.optional_services, + "extracted_guesses": draft.extracted_guesses, + "supplemental_fields": draft.supplemental_fields, + "document_count": FilingDocument.objects.filter(draft=draft).count(), + "party_count": FilingParty.objects.filter(draft=draft).count(), + "created_at": draft.created_at.isoformat() if draft.created_at else None, + "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, + "submitted_at": draft.submitted_at.isoformat() if draft.submitted_at else None, + } diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py new file mode 100644 index 0000000..9e36c6f --- /dev/null +++ b/efile_app/efile/services/efsp_errors.py @@ -0,0 +1,100 @@ +"""Turn an EFSP error response into one line worth showing a filer. + +The proxy answers a bad payload with a machine-readable description of which +fields are wrong rather than with an ``error`` string:: + + {"required_vars": [], + "optional_vars": [...], + "wrong_vars": [{"name": "al_court_bundle.elements[0].filing_type", + "description": "What filing type is this??", + "datatype": "choice", "currentVal": "", "choices": [...]}]} + +Reading only ``error`` out of that leaves the filer with "API returned status +400" and no way to act, while the response says exactly which document is +missing exactly which field. Shared by the fee quote and the submission so both +describe the same rejection the same way. +""" + +import json +import re + +# Field names the EFSP uses, in the words the UI uses for them. +_FIELD_LABELS = { + "document_type": "document type", + "efile_case_category": "case category", + "efile_case_subtype": "case subtype", + "efile_case_type": "case type", + "filing_component": "filing component", + "filing_parties": "filing parties", + "filing_type": "filing type", + "party_type": "party type", + "previous_case_id": "case number", + "tyler_payment_id": "payment account", +} + +# "al_court_bundle.elements[0].filing_type" -> document 1, field filing_type +_BUNDLE_FIELD = re.compile(r"^al_court_bundle\.elements\[(\d+)\]\.(.+)$") + +_MAX_RAW_BODY = 300 + + +def describe_efsp_error(response) -> str: + """Describe why the EFSP refused ``response``'s request. + + Never raises: a bad message is still better than a traceback on a path whose + only job is reporting someone else's failure. + """ + try: + body = response.json() + except (ValueError, json.JSONDecodeError): + text = (response.text or "").strip() + if text: + return f"the court's filing service returned status {response.status_code}: {text[:_MAX_RAW_BODY]}" + return f"the court's filing service returned status {response.status_code}" + + if not isinstance(body, dict): + return f"the court's filing service returned status {response.status_code}: {str(body)[:_MAX_RAW_BODY]}" + + problems = [ + *(_describe_var(var, missing=False) for var in _var_list(body, "wrong_vars")), + *(_describe_var(var, missing=True) for var in _var_list(body, "required_vars")), + ] + problems = [problem for problem in problems if problem] + if problems: + return "the court could not accept this filing: " + "; ".join(problems) + + # Some errors do arrive as a plain message. + error = body.get("error") or body.get("message") or body.get("detail") + if error: + message = str(error) + validation_errors = body.get("validation_errors") or body.get("errors") + if validation_errors: + message += f" - Validation errors: {validation_errors}" + return message + + return f"the court's filing service returned status {response.status_code}" + + +def _var_list(body, key): + value = body.get(key) + return [var for var in value if isinstance(var, dict)] if isinstance(value, list) else [] + + +def _describe_var(var, *, missing: bool) -> str: + name = str(var.get("name") or "").strip() + if not name: + return "" + + match = _BUNDLE_FIELD.match(name) + if match: + index, name = match.groups() + where = f" on document {int(index) + 1}" + else: + where = "" + + label = _FIELD_LABELS.get(name, name.replace("_", " ")) + current = str(var.get("currentVal") or "").strip() + + if missing or not current: + return f"no {label} was given{where}" + return f"{current!r} is not a {label} this court accepts{where}" diff --git a/efile_app/efile/services/efsp_payload.py b/efile_app/efile/services/efsp_payload.py new file mode 100644 index 0000000..c31af6a --- /dev/null +++ b/efile_app/efile/services/efsp_payload.py @@ -0,0 +1,258 @@ +"""Prepare a client-built filing payload before it is sent to the EFSP proxy. + +The fee quote and the final submission post the *same* ``efile_data`` blob to +two different EFSP endpoints. Every server-side adjustment to that blob belongs +here so the two cannot drift: a fee quoted against one payload and a filing made +with another is a bug that only shows up as a wrong number on a real filing. +""" + +import logging + +import requests +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +logger = logging.getLogger(__name__) + +# Labels the UI has historically stored in place of a real court filing-component +# code. None of these are meaningful to the EFSP. +_PLACEHOLDER_COMPONENT_LABELS = {"", "supporting", "attachment", "attachments"} + + +class PayloadValidationError(Exception): + """The payload cannot succeed at the EFSP, with a reason worth showing a filer. + + Raised only for conditions the court's own code lists already prove wrong, so + the message can be specific. Views turn this into a 400 carrying the message. + """ + + +def prepare_efile_payload(efile_data, jurisdiction_id, court_id): + """Apply every server-side fixup an EFSP request needs. Mutates ``efile_data``. + + Raises ``PayloadValidationError`` when the payload is knowably invalid. + """ + _drop_empty_cross_references(efile_data) + substitute_test_document_urls(efile_data) + validate_document_selections(efile_data) + resolve_placeholder_filing_components(efile_data, jurisdiction_id, court_id) + validate_required_party_types(efile_data, jurisdiction_id, court_id) + return efile_data + + +def _drop_empty_cross_references(efile_data): + """Omit ``cross_references`` entirely rather than sending an empty value.""" + if not efile_data.get("cross_references"): + efile_data.pop("cross_references", None) + + +def substitute_test_document_urls(efile_data): + """Point the proxy at a stand-in PDF when ``EFSP_TEST_DOCUMENT_URL`` is set. + + The proxy downloads every ``data_url`` itself -- for a fee quote just as much + as for a real filing -- and refuses any scheme other than http(s). A + LocalStack URL is not reachable from outside Docker, so exercising fees + locally would otherwise require public ingress to the dev machine. + + Documents are still uploaded to S3 for real and drafts still store their real + keys and URLs; only the URL handed to the proxy is replaced. + + Three independent things have to hold for a substitution to happen, so that no + single mistake can put a stand-in document in front of a real court: + + 1. ``settings_dev`` refuses to load on a deployed host at all. + 2. The setting is defined in ``settings_dev`` alone -- staging and production + never read the environment variable, so exporting it there does nothing. + 3. This function refuses to substitute when ``DEBUG`` is off, and raises + rather than quietly falling back, because a production process holding a + stand-in URL is a broken deploy and should not file anything. + """ + test_url = getattr(settings, "EFSP_TEST_DOCUMENT_URL", "") + if not test_url: + return + + if not settings.DEBUG: + # Fail closed and loud. Silently sending the real document would leave a + # misconfigured deploy running and undiagnosed until it did something + # worse; silently sending the stand-in would file a placeholder PDF as + # though it were the filer's document. + raise ImproperlyConfigured( + "EFSP_TEST_DOCUMENT_URL is set while DEBUG is False. The stand-in filing " + "document is a local-development affordance and must never reach a real " + "court. Unset EFSP_TEST_DOCUMENT_URL in this environment." + ) + + substituted = 0 + for bundle in efile_data.get("al_court_bundle", []): + if bundle.get("data_url") == test_url: + continue + bundle["data_url"] = test_url + substituted += 1 + + if substituted: + logger.warning( + "EFSP_TEST_DOCUMENT_URL is set: sent %d stand-in document(s) to the EFSP instead of the " + "uploaded file(s). Fees and filings from this request do not reflect real documents.", + substituted, + ) + + +def validate_document_selections(efile_data): + """Reject a document that carries no filing type. + + The filing type is chosen per document on the upload screen, and a draft can + reach the fee quote without one: an interrupted session, or an upload page + running a stale script whose own check was missing. The blank travels as + ``"filing_type": ""`` and the EFSP answers with a `wrong_vars` entry naming + ``al_court_bundle.elements[0].filing_type`` and a bare list of code numbers. + + Only the filing type is checked. It is required for every court -- the code + lists are keyed on it -- while document type and the rest vary by court, and + this module refuses payloads only where the court's own rules already prove + them wrong. + """ + missing = [ + _document_label(bundle, index) + for index, bundle in enumerate(efile_data.get("al_court_bundle", [])) + if not str(bundle.get("filing_type") or "").strip() + ] + if not missing: + return + + raise PayloadValidationError( + f"No filing type is set for: {', '.join(missing)}. Go back to the documents step " + f"and choose a filing type for each document." + ) + + +def _document_label(bundle, index): + """Name a bundle the way the filer would recognise it.""" + for key in ("filename", "filing_description", "document_description"): + label = str(bundle.get(key) or "").strip() + if label: + return label + return f"document {index + 1}" + + +def resolve_placeholder_filing_components(efile_data, jurisdiction_id, court_id): + """Replace leftover UI labels such as ``"supporting"`` with the court's code. + + Newer uploads store the real code, so this only fires for drafts saved by an + older client. Results are cached per filing type because a bundle commonly + repeats the same one. + """ + bundles = [ + bundle + for bundle in efile_data.get("al_court_bundle", []) + if str(bundle.get("filing_component", "")).lower() in _PLACEHOLDER_COMPONENT_LABELS + ] + if not bundles: + return + + resolved_codes: dict[str, str | None] = {} + for bundle in bundles: + filing_type = bundle.get("filing_type") + if not filing_type: + continue + if filing_type not in resolved_codes: + resolved_codes[filing_type] = _lookup_attachment_component(jurisdiction_id, court_id, filing_type) + code = resolved_codes[filing_type] + if code: + bundle["filing_component"] = code + + +def validate_required_party_types(efile_data, jurisdiction_id, court_id): + """Reject a payload that leaves one of the court's required party types empty. + + A case type declares which party types are mandatory -- a Cook County civil + case needs both a Plaintiff and a Defendant. Nothing in the UI stops a filer + from choosing the same type for themselves and for the other side, and the + EFSP answers that with an opaque 400 ("All required parties not covered by + existing party types. ([173174]. Missing [173180]") that reaches the filer as + a generic failure with no way to act on it. + + Checked here rather than in the browser so the fee quote and the submission + share one answer, and so it holds regardless of what the client sends. + + Fails open: if the court's party-type list cannot be fetched, the payload goes + through and the EFSP stays the authority. This check exists to produce a + better message, not to be a second gatekeeper. + """ + case_type = efile_data.get("efile_case_type") + if not case_type: + return + + required = _lookup_required_party_types(jurisdiction_id, court_id, case_type) + if not required: + return + + present = { + str(party.get("party_type")) + for party in [*efile_data.get("users", []), *efile_data.get("other_parties", [])] + if party.get("party_type") + } + missing = {code: name for code, name in required.items() if code not in present} + if not missing: + return + + missing_names = ", ".join(sorted(missing.values())) + raise PayloadValidationError( + f"This case type requires a party of every required type, and none was given for: " + f"{missing_names}. Go back to the case details and give each side a different party type." + ) + + +def _lookup_required_party_types(jurisdiction_id, court_id, case_type): + """Return ``{code: name}`` for the case type's required party types. + + Empty when the list cannot be fetched or the court marks nothing required, so + callers treat "unknown" and "nothing required" alike -- both mean "do not + block on this". + """ + url = ( + f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/codes/courts/{court_id}/" + f"case_types/{case_type}/party_types" + ) + try: + response = requests.get(url, timeout=10) + if response.status_code != 200: + return {} + party_types = response.json() + except (requests.RequestException, ValueError) as error: + logger.warning("Could not resolve required party types for case type %s: %s", case_type, error) + return {} + + if not isinstance(party_types, list): + return {} + + return { + str(party_type.get("code")): party_type.get("name") or str(party_type.get("code")) + for party_type in party_types + # The EFSP renders this as a JSON boolean, but Tyler has been seen sending + # the string "true" for the same field elsewhere in the code lists. + if isinstance(party_type, dict) and str(party_type.get("isrequired", "")).lower() == "true" + } + + +def _lookup_attachment_component(jurisdiction_id, court_id, filing_type): + """Return the court's attachment filing-component code, or None if unavailable.""" + url = ( + f"{settings.EFSP_URL}/jurisdictions/{jurisdiction_id}/codes/courts/{court_id}/" + f"filing_types/{filing_type}/filing_components" + ) + try: + response = requests.get(url, timeout=10) + if response.status_code != 200: + return None + components = response.json() + except (requests.RequestException, ValueError) as error: + logger.warning("Could not resolve filing component for filing type %s: %s", filing_type, error) + return None + + if not isinstance(components, list): + return None + + for component in components: + if isinstance(component, dict) and str(component.get("name", "")).lower() in {"attachment", "attachments"}: + return component.get("code") + return None diff --git a/efile_app/efile/services/submission_errors.py b/efile_app/efile/services/submission_errors.py new file mode 100644 index 0000000..d9a0121 --- /dev/null +++ b/efile_app/efile/services/submission_errors.py @@ -0,0 +1,26 @@ +"""Stable error codes used by the final submission flow.""" + + +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" + EFILE_DATA_INVALID = "submission_efile_data_invalid" + COURT_ID_MISSING = "submission_court_id_missing" + PAYLOAD_VALIDATION_FAILED = "submission_payload_validation_failed" + + +PRE_SUBMIT_ERROR_CODES = frozenset( + { + SubmissionErrorCode.CONFIRMATION_REQUIRED, + SubmissionErrorCode.CASE_DATA_MISSING, + SubmissionErrorCode.UPLOAD_DATA_MISSING, + SubmissionErrorCode.EFILE_DATA_MISSING, + SubmissionErrorCode.EFILE_DATA_INVALID, + SubmissionErrorCode.COURT_ID_MISSING, + SubmissionErrorCode.PAYLOAD_VALIDATION_FAILED, + } +) diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index 3a13a32..2c2b2eb 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -16,7 +16,8 @@ DEBUG = True # Override in env-specific settings -ALLOWED_HOSTS: list[str] = [] +_configured_allowed_hosts = os.getenv("DJANGO_ALLOWED_HOSTS", "") +ALLOWED_HOSTS: list[str] = [host.strip() for host in _configured_allowed_hosts.split(",") if host.strip()] # Override in env-specific settings CSRF_TRUSTED_ORIGINS: list[str] = [] diff --git a/efile_app/efile/settings_dev.py b/efile_app/efile/settings_dev.py index f97d173..e154d30 100644 --- a/efile_app/efile/settings_dev.py +++ b/efile_app/efile/settings_dev.py @@ -2,8 +2,31 @@ from pathlib import Path import dj_database_url +from django.core.exceptions import ImproperlyConfigured from dotenv import load_dotenv +# Refuse to be the settings module on a deployed host. +# +# `efile/settings.py` is a bare re-export of this module, and manage.py, wsgi.py +# and asgi.py all `setdefault("DJANGO_SETTINGS_MODULE", "efile.settings")`. So a +# deploy that loses DJANGO_SETTINGS_MODULE -- an edit to fly.toml's [env] block, +# a new machine started without it -- silently falls back to *development* +# settings: DEBUG=True, and EFSP_TEST_DOCUMENT_URL live, which would file +# stand-in PDFs against a real court. Every other guard on that setting keys off +# DEBUG, so none of them would fire in exactly this case. +# +# Fly always sets FLY_APP_NAME in the runtime environment, so its presence means +# "deployed" regardless of what DJANGO_SETTINGS_MODULE says. Failing at import +# turns a silent misconfiguration into a boot failure. Deliberately no override +# env var: an escape hatch here is the same footgun again. +if os.getenv("FLY_APP_NAME"): + raise ImproperlyConfigured( + "efile.settings_dev was loaded on a deployed host (FLY_APP_NAME=" + f"{os.environ['FLY_APP_NAME']!r}). Development settings enable DEBUG and the " + "EFSP stand-in document. Set DJANGO_SETTINGS_MODULE to efile.settings_staging " + "or efile.settings_prod." + ) + # Determine BASE_DIR without importing base to load .env first _BASE_DIR = Path(__file__).resolve().parent.parent @@ -11,13 +34,58 @@ load_dotenv(dotenv_path=_BASE_DIR / ".env", override=False) from efile.settings_base import * # noqa: E402,F401,F403 +from efile.settings_base import ALLOWED_HOSTS as BASE_ALLOWED_HOSTS # noqa: E402 from efile.settings_base import DATABASES as BASE_DATABASES # noqa: E402 +from efile.settings_base import INSTALLED_APPS as BASE_INSTALLED_APPS # noqa: E402 +from efile.settings_base import MIDDLEWARE as BASE_MIDDLEWARE # noqa: E402 # Bind DATABASES explicitly to avoid F405 and make linter aware of the symbol DATABASES = BASE_DATABASES DEBUG = True -ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".localhost", "[::1]", "testserver"] +ALLOWED_HOSTS = BASE_ALLOWED_HOSTS or ["localhost", "127.0.0.1", ".localhost", "[::1]", "testserver"] + +# Static files: serve dev assets through WhiteNoise, as staging and production do. +# +# `runserver`'s own static handler sends Last-Modified and an ETag but no +# Cache-Control, which leaves the browser free to guess a freshness lifetime +# (Chrome: a fraction of the file's age) and reuse a script for hours without +# revalidating. A JS change then reaches the tests and never reaches the browser +# -- which is how an upload page shipped a validation check that the running app +# did not have, and let a filing go out with no filing type selected. +# +# ManifestStaticFilesStorage covers the deployed environments by content-hashing +# each filename, so the fix belongs here rather than in a ?v= query string in the +# templates: a hand-maintained cache-buster only helps the files someone +# remembered to bump. +# +# `whitenoise.runserver_nostatic` must precede django.contrib.staticfiles to stop +# runserver from installing its handler ahead of the middleware. USE_FINDERS +# serves straight from STATICFILES_DIRS (no collectstatic in dev), AUTOREFRESH +# re-stats each file per request, and MAX_AGE=0 makes the browser revalidate +# every time. +INSTALLED_APPS = ["whitenoise.runserver_nostatic", *BASE_INSTALLED_APPS] +MIDDLEWARE = list(BASE_MIDDLEWARE) +MIDDLEWARE.insert( + MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1, + "whitenoise.middleware.WhiteNoiseMiddleware", +) +WHITENOISE_USE_FINDERS = True +WHITENOISE_AUTOREFRESH = True +WHITENOISE_MAX_AGE = 0 + +# Opt-in: send this URL to the EFSP proxy as every document's `data_url` instead +# of the real S3 URL. +# +# The proxy downloads each `data_url` itself -- for a fee quote as well as for a +# filing -- and only accepts http(s), so a LocalStack URL is unreachable to it. +# Setting this to any publicly readable PDF lets fee quotes and submissions run +# end-to-end from a laptop with no ingress, tunnel, or public bucket. +# +# Uploads still go to S3/LocalStack for real and drafts still store the real keys +# and URLs; only the URL handed to the proxy changes. Defined here rather than in +# settings_base so no environment variable can enable it outside development. +EFSP_TEST_DOCUMENT_URL = os.getenv("EFSP_TEST_DOCUMENT_URL", "").strip() CSRF_TRUSTED_ORIGINS = [ "http://localhost", "http://127.0.0.1", diff --git a/efile_app/efile/settings_prod.py b/efile_app/efile/settings_prod.py index 145c7fe..19f83fc 100644 --- a/efile_app/efile/settings_prod.py +++ b/efile_app/efile/settings_prod.py @@ -3,7 +3,11 @@ import dj_database_url from efile.settings_base import * # noqa: F401,F403 + +# Import specific names with aliases to avoid F405 from star import usage +from efile.settings_base import BASE_DIR as BASE_SETTINGS_DIR from efile.settings_base import DATABASES as BASE_DATABASES +from efile.settings_base import MIDDLEWARE as BASE_MIDDLEWARE # Bind DATABASES explicitly to avoid F405 and make linter aware of the symbol DATABASES = BASE_DATABASES @@ -12,6 +16,22 @@ ALLOWED_HOSTS = ["forms-mvp-prod.fly.dev"] CSRF_TRUSTED_ORIGINS = ["https://forms-mvp-prod.fly.dev"] +# Static files (WhiteNoise), mirroring settings_staging. +# +# ManifestStaticFilesStorage content-hashes each filename (payment.js -> +# payment.4a3f9c2b.js), so a changed asset gets a new URL and an unchanged one +# keeps its cache entry. This is why templates carry no manual ?v= cache-buster. +STATIC_ROOT = BASE_SETTINGS_DIR / "staticfiles" +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +# Build MIDDLEWARE from base and insert WhiteNoise right after SecurityMiddleware +MIDDLEWARE = list(BASE_MIDDLEWARE) +try: + security_index = MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") +except ValueError: + security_index = -1 +MIDDLEWARE.insert(security_index + 1 if security_index >= 0 else 0, "whitenoise.middleware.WhiteNoiseMiddleware") + # Security hardening SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") if not SECRET_KEY or SECRET_KEY.startswith("django-insecure-"): diff --git a/efile_app/efile/static/js/api-utils.js b/efile_app/efile/static/js/api-utils.js index a8c2247..f7266e4 100644 --- a/efile_app/efile/static/js/api-utils.js +++ b/efile_app/efile/static/js/api-utils.js @@ -3,6 +3,20 @@ * Features: CSRF handling, request building, error handling */ class ApiUtils { + /** + * Timeout for calls that reach the EFSP proxy, which in turn calls Tyler: + * fee quotes and filing submissions. Round trips over 40s have been observed + * on real courts (Adams County order-of-protection fees), against a 30s + * default that reported "Request timed out" for a request the server went on + * to answer with a valid $0.00 quote. + * + * Deliberately longer than the server's own 60s timeout on that call, so the + * server is what decides a request has failed. The browser giving up first + * only hides the outcome: this timeout does not abort the request, so the + * filing continues regardless of what the filer is shown. + */ + static FILING_TIMEOUT_MS = 120000; + constructor() { this.baseUrl = window.location.origin; this.csrfToken = this.getCSRFToken(); @@ -181,19 +195,39 @@ class ApiUtils { requestOptions.body = JSON.stringify(data); } - // Create a timeout promise + // Create a timeout promise. The timer is cleared once the race + // settles: it is not cancelled by losing, and an uncleared one keeps + // a pending task alive for the full budget after the response is + // already in hand -- up to two minutes on a filing call. + let timeoutId; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Request timeout')), timeout); + timeoutId = setTimeout(() => reject(new Error('Request timeout')), timeout); }); // Race between fetch and timeout - const response = await Promise.race([ - fetch(url, requestOptions), - timeoutPromise - ]); + let response; + try { + response = await Promise.race([ + fetch(url, requestOptions), + timeoutPromise + ]); + } finally { + clearTimeout(timeoutId); + } if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + // Prefer the server's own message. Our API answers failures with + // {success: false, error: "..."}, and that text is often the only + // actionable thing the filer can be told -- which required party + // is missing, which document the EFSP could not fetch. Collapsing + // every 400 to "Invalid request" throws exactly that away. + const serverMessage = await response.clone().json() + .then(body => body?.error) + .catch(() => null); + const error = new Error(serverMessage || `HTTP error! status: ${response.status}`); + error.status = response.status; + error.serverMessage = serverMessage || null; + throw error; } const result = await response.json(); @@ -218,6 +252,11 @@ class ApiUtils { } handleApiError(error) { + // A message the server wrote is always more useful than the status-code + // wording below, so it passes through untouched. + if (error.serverMessage) { + return error; + } if (error.message === 'Request timeout') { return new Error('Request timed out. Please check your connection and try again.'); } else if (error.message.includes('Failed to fetch')) { @@ -272,37 +311,13 @@ class ApiUtils { return response; } - async cachedPost(endpoint, body) { - // Check cache first - const cachedResponse = this.getCachedResponse(endpoint, body); - if (cachedResponse !== null) { - return cachedResponse; - } - - let headers = { - "Content-Type": "application/json", - "X-CSRFToken": apiUtils.getCSRFToken(), - }; - // Make API request if not cached - const response = await this.makeRequest(endpoint, { - method: "POST", - headers, - data: body, - }); - - // Cache the response - this.setCachedResponse(endpoint, body, response); - - return response; - - } - - async post(endpoint, data = {}, params = {}) { + async post(endpoint, data = {}, params = {}, options = {}) { return this.makeRequest(endpoint, { method: 'POST', data, - params + params, + ...options }); } @@ -329,42 +344,31 @@ class ApiUtils { }); } + // Draft state (case/upload data) is owned by the server-side FilingDraft + // model. It must never be cached in localStorage: a stale blob could survive + // a submit/reset and leak into the next filing. These always hit the server. async getCaseData() { - return this.get("/api/get-case-data", {}, true); + return this.fetchJSON("/api/get-case-data", "GET"); } async saveCaseData(body) { - let resp = this.fetchJSON("/api/save-case-data/", "POST", {}, body) - const cacheKey = this.getCacheKey("/api/get-case-data", {}); - this.clearCache(cacheKey) - return resp; + return this.fetchJSON("/api/save-case-data/", "POST", {}, body); } - /** Calling `get-party-types` will set certain values on the case. For now, just nuke the cache.. */ async getPartyTypes(params) { - let resp = this.fetchJSON("/api/get-party-types", "GET", params); - const cacheKey = this.getCacheKey("/api/get-case-data", {}); - this.clearCache(cacheKey) - return resp; + return this.fetchJSON("/api/get-party-types", "GET", params); } async getUploadData() { - return this.get("/api/get-upload-data", {}, true); + return this.fetchJSON("/api/get-upload-data", "GET"); } async saveUploadData(body) { - return this._saveUpload("/api/save-upload-data/", body); + return this.fetchJSON("/api/save-upload-data/", "POST", {}, body); } async saveFirstUploadData(body) { - return this._saveUpload("/api/save-upload-data-first/", body); - } - - async _saveUpload(endpoint, body) { - let resp = this.fetchJSON(endpoint, "POST", {}, body); - const cacheKey = this.getCacheKey("/api/get-upload-data", {}); - this.clearCache(cacheKey) - return resp; + return this.fetchJSON("/api/save-upload-data-first/", "POST", {}, body); } // Cache management methods diff --git a/efile_app/efile/static/js/cascading-dropdowns.js b/efile_app/efile/static/js/cascading-dropdowns.js index 2b27086..64f1c40 100644 --- a/efile_app/efile/static/js/cascading-dropdowns.js +++ b/efile_app/efile/static/js/cascading-dropdowns.js @@ -30,6 +30,9 @@ class CascadingDropdowns { case_type: null, party_type: null, }; + // A new filing has no uploaded document yet, so the upload-data + // response may not contain document classification guesses. + this.guesses = {}; this.optionalServicesLoaded = false; this.isAutomaticSelection = false; // Track if selection is automatic } @@ -135,8 +138,15 @@ class CascadingDropdowns { } async loadGuesses() { - const data = await apiUtils.getUploadData(); - this.guesses = data['guesses'] + try { + const data = await apiUtils.getUploadData(); + this.guesses = data?.guesses || {}; + } catch (error) { + // Guesses are optional and should never prevent a new filing from + // using the cascading dropdowns. + console.warn("Could not load upload guesses:", error); + this.guesses = {}; + } } async loadDropdownData(fieldId, endpoint, params = {}) { @@ -190,6 +200,9 @@ class CascadingDropdowns { } handleDropdownChange(dropdown) { + // Upload guesses are optional. Keep the change handler safe even if a + // stale/partial response or another caller clears the property. + this.guesses = this.guesses || {}; const fieldId = dropdown.id; const selectedValue = dropdown.value; const mapping = this.dropdownMapping[fieldId]; @@ -259,8 +272,8 @@ class CascadingDropdowns { if (this.validateParameters(fieldId, params)) { // Load data for the next dropdown only if there is a next dropdown if (mapping.next && mapping.endpoint) { - params.guessed_case_category = this.guesses['case category']; - params.guessed_case_type = this.guesses['case type']; + params.guessed_case_category = this.guesses?.['case category']; + params.guessed_case_type = this.guesses?.['case type']; params.only_required = true; this.loadDropdownData(mapping.next, mapping.endpoint, params); } diff --git a/efile_app/efile/static/js/filing-payload.js b/efile_app/efile/static/js/filing-payload.js new file mode 100644 index 0000000..2b81443 --- /dev/null +++ b/efile_app/efile/static/js/filing-payload.js @@ -0,0 +1,270 @@ +/** + * Shared filing-payload construction. + * + * The review page and the payment page each build the exact same `efile_data` + * blob for the EFSP -- the payment page to quote fees, the review page to quote + * fees and to submit. These functions were duplicated verbatim in payment.js and + * review.js; a fix applied to one and not the other produced a fee quote that + * disagreed with the filing, so they live here once. + * + * Mixed into each page's `FilingHandler` with Object.assign, so `this` still + * refers to the host handler and page-specific hooks (`setFeesState`, + * `setSubmissionState`) resolve normally. + * + * Depends on the page-level globals `Messages`, `apiUtils`, and `gettext`. + */ +/** + * Normalise a filing component to its bare code. + * + * The upload page stores the selection as `{id, name}` on the file record while + * the durable draft stores a plain code string, so both shapes reach here. Note + * `typeof null === "object"`, hence the explicit truthiness check -- an older + * session with `filing_component: null` would otherwise throw on `.id`. + * + * @param {Object|string|null|undefined} value + * @returns {string} the code, or "" when there isn't one + */ +function componentCode(value) { + if (value && typeof value === "object") { + return value.id || value.code || ""; + } + return value || ""; +} + +const FilingPayload = { + buildEFilingData(userData, caseData, uploadData, paymentAccountID) { + const nameParts = userData.fullName.split(" "); + const firstName = nameParts[0] || ""; + const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ""; + const middleName = nameParts.length > 2 ? nameParts.slice(1, -1).join(" ") : ""; + + const partyType = caseData.determined_party_type || caseData.petitioner_party_type || caseData.party_type; + + if (!partyType) { + throw new Error('Party type could not be determined. This is required for eFiling.'); + } + + // Build user object + const mainUser = { + mobile_number: userData.phone, + phone_number: userData.phone, + address: { + address: userData.address, + unit: userData.addressLine2, + city: userData.city, + state: userData.state, + zip: userData.zip, + country: "US" + }, + email: userData.email, + party_type: partyType, + date_of_birth: "", + // The authenticated account is a firm filer, not a self-represented + // party. Marking the filer as the party makes Tyler reject fees with + // "Cannot specify self as party for a firm filer." + is_form_filler: false, + name: { + first: firstName, + middle: middleName, + last: lastName, + suffix: "" + }, + is_new: true + }; + + const users = [mainUser]; + + // Add second user if needed for name changes + if (caseData.new_name_party_type) { + users.push({ + ...mainUser, + party_type: caseData.new_name_party_type, + name: { + first: caseData.new_first_name || firstName, + middle: caseData.new_middle_name || middleName, + last: caseData.new_last_name || lastName, + suffix: caseData.new_suffix || "" + } + }); + } + + // Add second user if needed for name changes + if (caseData.respondent_name_party_type) { + users.push({ + party_type: caseData.respondent_name_party_type, + name: { + first: caseData.respondent_first_name || "", + middle: caseData.respondent_middle_name || "", + last: caseData.respondent_last_name || "", + suffix: caseData.respondent_suffix || "" + }, + is_new: true, + }); + } + + let other_parties = []; + + if (caseData.other_first_name && caseData.other_party_type) { + other_parties.push({ + party_type: caseData.other_party_type, + name: { + first: caseData.other_first_name, + last: caseData.other_last_name + }, + address: { + address: caseData.other_address_line_1, + unit: caseData.other_address_line_2, + city: caseData.other_address_city, + state: caseData.other_address_state, + zip: caseData.other_address_zip, + country: "US" + }, + email: caseData.other_email, + phone_number: caseData.other_phone_number, + is_new: true, + }); + } + + const efilingData = { + efile_case_category: caseData.case_category, + efile_case_type: caseData.case_type, + efile_case_subtype: caseData.case_subtype, + previous_case_id: caseData?.previous_case_id, + docket_number: caseData?.docket_number, + users, + other_parties, + user_started_case: !caseData?.previous_case_id, + al_court_bundle: [], + comments_to_clerk: "", + tyler_payment_id: paymentAccountID, + lead_contact: { + name: { + first: firstName, + middle: middleName, + last: lastName + }, + email: userData.email + }, + return_date: "" + }; + + // Add court bundles for documents + this.addCourtBundles(efilingData, uploadData, caseData, users); + + return efilingData; + }, + + addCourtBundles(efilingData, uploadData, caseData, users) { + const courtName = caseData.court_name || caseData.court || ""; + if (courtName.toLowerCase().includes("cook") || courtName.toLowerCase().includes("dupage")) { + efilingData.cross_references = { + 254500: "254500" + }; + } + + // Add lead document + if (uploadData?.files?.lead) { + const leadBundle = this.createDocumentBundle( + uploadData.files.lead, + uploadData.lead_filing_type || caseData.filing_type, + uploadData.lead_document_type || caseData.document_type, + uploadData.lead_filing_component || caseData.filing_component, + users, + uploadData.lead_filing_type_name || caseData.case_type_name, + uploadData.lead_document_type_name || "", + uploadData.lead_cc_email + ); + efilingData.al_court_bundle.push(leadBundle); + } + + // Add supporting documents + if (uploadData?.files?.supporting?.length > 0) { + uploadData.files.supporting.forEach((doc, index) => { + const config = uploadData.supporting_documents?.[index] || {}; + const filingComponent = componentCode(config.filing_component) || + componentCode(doc.filing_component); + const bundle = this.createDocumentBundle( + doc, + config.filing_type || caseData.filing_type_id, + config.document_type || caseData.document_type, + filingComponent || caseData.filing_component, + users, + config.filing_type_name || `Supporting Document ${index + 1}`, + config.document_type_name || "", + config.cc_email + ); + efilingData.al_court_bundle.push(bundle); + }); + } + }, + + createDocumentBundle(doc, filingType, documentType, filingComponent, users, description, docDescription, cc_email) { + if (cc_email) { + courtesy_copies = [cc_email] + } else { + courtesy_copies = [] + } + return { + proxy_enabled: true, + filing_type: filingType, + optional_services: [], + due_date: null, + filing_description: description, + reference_number: "", + filing_attorney: "", + filing_comment: "", + courtesy_copies: courtesy_copies, + preliminary_copies: [], + filing_parties: users.length === 1 ? ["users[0]"] : ["users[0]", "users[1]"], + filing_action: "efile", + tyler_merge_attachments: false, + document_type: documentType, + filing_component: filingComponent, + filename: doc.name, + document_description: docDescription, + data_url: doc.url || doc.s3_url || doc.file_url || doc.download_url + }; + }, + + handleSubmissionResult(result) { + if (result?.success) { + Messages.showSuccess(gettext("Filing submitted successfully! You will be redirected to the confirmation page.")); + setTimeout(() => { + const jurisdiction = apiUtils.getCurrentJurisdiction(); + window.location.href = result.redirect_url || `/jurisdiction/${jurisdiction}/filing-confirmation/`; + }, 2000); + } else { + Messages.showError(result?.error || "An error occurred during submission."); + this.setSubmissionState(false); + } + }, + + handleFeesResponse(result) { + if (result?.success) { + let htmlStr = ` + Total: $${result.api_response.feesCalculationAmount.value} + +