diff --git a/docs/screenshots/reorganized-flow/06-document-checklist.png b/docs/screenshots/reorganized-flow/06-document-checklist.png new file mode 100644 index 0000000..b3ac568 Binary files /dev/null and b/docs/screenshots/reorganized-flow/06-document-checklist.png differ diff --git a/docs/screenshots/reorganized-flow/07-organize-documents.png b/docs/screenshots/reorganized-flow/07-organize-documents.png new file mode 100644 index 0000000..bab3fd9 Binary files /dev/null and b/docs/screenshots/reorganized-flow/07-organize-documents.png differ diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index 43e5403..d0e6eab 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -30,7 +30,11 @@ def prioritize_options(api_data, guessed): if isinstance(api_data, list): for opt in api_data: if isinstance(opt, dict) and "code" in opt and "name" in opt: - options.append({"value": opt["code"], "text": opt["name"]}) + # Keep other fields the court sends (e.g. "amountincontroversy" on + # filing types) available to callers that need more than value/text, + # without every caller having to know the raw Tyler field names. + extra = {key: value for key, value in opt.items() if key not in ("code", "name")} + options.append({"value": opt["code"], "text": opt["name"], **extra}) options.sort(key=lambda x: x["text"]) if not guessed: @@ -534,6 +538,48 @@ def get_document_types(request): except Exception as e: return DropdownAPIViews.error_response(f"Error: {str(e)}") + @staticmethod + @require_http_methods(["GET"]) + def get_name_suffixes(request): + """Get the court's accepted name suffixes (Jr., Sr., II, ...). + + A suffix has to exactly match one of these for Tyler to accept the + party -- it isn't free text, even though it looks like it could be. + """ + try: + court_code = request.GET.get("court") + jurisdiction = get_jurisdiction_from_request(request) + + if not jurisdiction: + return DropdownAPIViews.error_response("Missing required jurisdiction parameter") + + if not court_code: + return DropdownAPIViews.error_response("Missing required court parameter") + + api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/{court_code}/name_suffixes" + logger.debug("GET %s", api_url) + response = requests.get(api_url, timeout=10) + + if response.status_code == 200: + api_data = response.json() + suffixes = ( + [ + {"value": item["code"], "text": item["name"]} + for item in api_data + if isinstance(item, dict) and item.get("code") and item.get("name") + ] + if isinstance(api_data, list) + else [] + ) + return DropdownAPIViews.success_response(suffixes) + else: + return DropdownAPIViews.error_response(f"API request failed with status {response.status_code}") + + except (requests.RequestException, requests.Timeout) as api_error: + return DropdownAPIViews.error_response(f"API request failed: {str(api_error)}") + except Exception as e: + return DropdownAPIViews.error_response(f"Error: {str(e)}") + @staticmethod @require_http_methods(["GET"]) def get_optional_services(request): @@ -685,3 +731,4 @@ def get_party_types(request): get_document_types = DropdownAPIViews.get_document_types get_optional_services = DropdownAPIViews.get_optional_services get_party_types = DropdownAPIViews.get_party_types +get_name_suffixes = DropdownAPIViews.get_name_suffixes diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index 605b1e1..1fbf375 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -20,6 +20,7 @@ get_courts, get_document_types, get_filing_types, + get_name_suffixes, get_optional_services, get_party_types, ) @@ -47,6 +48,7 @@ path("dropdowns/document-types/", get_document_types, name="document_types"), path("dropdowns/optional-services/", get_optional_services, name="optional_services"), path("dropdowns/party-types/", get_party_types, name="party_types"), + path("dropdowns/name-suffixes/", get_name_suffixes, name="name_suffixes"), # Form configuration endpoints path("form-config/", get_form_config, name="form_config"), path("case-type-config/", get_case_type_config, name="case_type_config"), diff --git a/efile_app/efile/migrations/0005_document_checklist_state.py b/efile_app/efile/migrations/0005_document_checklist_state.py new file mode 100644 index 0000000..4269b05 --- /dev/null +++ b/efile_app/efile/migrations/0005_document_checklist_state.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("efile", "0004_filingdraft_case_title")] + + operations = [ + migrations.AddField( + model_name="filingdraft", + name="document_checklist_acknowledged", + field=models.BooleanField(default=False), + ), + ] diff --git a/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py b/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py new file mode 100644 index 0000000..d95b53e --- /dev/null +++ b/efile_app/efile/migrations/0006_filingdocument_requested_optional_services.py @@ -0,0 +1,13 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("efile", "0005_document_checklist_state")] + + operations = [ + migrations.AddField( + model_name="filingdocument", + name="requested_optional_services", + field=models.JSONField(default=list, blank=True), + ), + ] diff --git a/efile_app/efile/migrations/0007_amount_in_controversy.py b/efile_app/efile/migrations/0007_amount_in_controversy.py new file mode 100644 index 0000000..5990fa7 --- /dev/null +++ b/efile_app/efile/migrations/0007_amount_in_controversy.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("efile", "0006_filingdocument_requested_optional_services")] + + operations = [ + migrations.AddField( + model_name="filingdraft", + name="amount_in_controversy", + field=models.CharField(max_length=50, blank=True), + ), + migrations.AddField( + model_name="filingdocument", + name="filing_requires_amount_in_controversy", + field=models.BooleanField(default=False), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 7ffe872..7969168 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -85,6 +85,12 @@ class Status(models.TextChoices): optional_services = models.JSONField(default=list, blank=True) extracted_guesses = models.JSONField(default=dict, blank=True) + document_checklist_acknowledged = models.BooleanField(default=False) + # The dollar amount at stake, required by the EFSP when any document's + # filing type is flagged "amountincontroversy: Required". Stored as text + # (like the fee fields) since it's echoed back to the API rather than + # computed on. + amount_in_controversy = models.CharField(max_length=50, 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 @@ -144,8 +150,17 @@ class Role(models.TextChoices): 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) + # The court's own "amountincontroversy" flag for this document's filing + # type (from the filing-types codes API) is "Required" for some case + # types. Recorded per document, since each can carry a different filing + # type; case_questions asks for the dollar amount if any document needs it. + filing_requires_amount_in_controversy = models.BooleanField(default=False) courtesy_copy_email = models.EmailField(blank=True) + # Codes selected from the court's optional-services list for this document + # (e.g. a certified copy), scoped per document since each can have its own + # filing type. See efile.api.dropdown_views.get_optional_services. + requested_optional_services = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/efile_app/efile/services/document_uploads.py b/efile_app/efile/services/document_uploads.py new file mode 100644 index 0000000..17f137e --- /dev/null +++ b/efile_app/efile/services/document_uploads.py @@ -0,0 +1,93 @@ +import logging +import os +from tempfile import NamedTemporaryFile + +from efile.models import FilingDocument +from efile.services.drafts import read_upload_data, write_upload_data +from efile.utils.llms import LlmError, extract_fields_from_file +from efile.utils.s3_upload_handler import S3UploadHandler +from efile.views.session_api import llm_fields, llm_hints +from efile.workflow import WorkflowStepKey + +logger = logging.getLogger(__name__) + + +def _analyze_lead(uploaded_file, jurisdiction): + temp_path = None + try: + uploaded_file.seek(0) + with NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file: + for chunk in uploaded_file.chunks(): + temp_file.write(chunk) + temp_path = temp_file.name + return extract_fields_from_file( + temp_path, + llm_fields.get(jurisdiction, llm_fields["default"]), + llm_hint=llm_hints.get(jurisdiction, llm_hints["default"]), + ) + except LlmError: + logger.exception("Document extraction failed") + return {} + finally: + if temp_path: + try: + os.unlink(temp_path) + except OSError: + logger.warning("Could not remove extraction temp file %s", temp_path) + + +def _guess_payload(found_fields): + return { + "court": found_fields.get("court name"), + "filing type": found_fields.get("filing type"), + "case category": found_fields.get("case category"), + "case type": found_fields.get("case type"), + "docket number": found_fields.get("docket number") or found_fields.get("docker number"), + } + + +def upload_files(draft, uploaded_files, jurisdiction, *, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS): + """Upload PDFs and merge them into the draft without discarding existing files.""" + + handler = S3UploadHandler() + if not handler._ensure_initialized(): + raise ValueError("Document storage is not configured. Please try again later.") + + current = read_upload_data(draft) + files = current.setdefault("files", {}) + supporting = list(files.get("supporting", [])) + found_lead = False + + for uploaded_file in uploaded_files: + validation = handler.validate_file(uploaded_file, max_size_mb=10, allowed_types=[".pdf"]) + if not validation["valid"]: + raise ValueError(f"{uploaded_file.name}: {validation['error']}") + + is_lead = not files.get("lead") and not found_lead + role = FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING + + # Analyze the file while it's still open; boto3 closes the fileobj it's given once uploaded. + guesses = _guess_payload(_analyze_lead(uploaded_file, jurisdiction)) if is_lead else None + + uploaded_file.seek(0) + result = handler.upload_file(uploaded_file, file_type=role) + if not result["success"]: + raise ValueError(result.get("error", f"Could not upload {uploaded_file.name}.")) + + file_data = { + "name": uploaded_file.name, + "size": uploaded_file.size, + "type": uploaded_file.content_type, + "url": handler.get_public_url(result["key"]), + "s3_key": result["key"], + } + if is_lead: + files["lead"] = file_data + found_lead = True + current["guesses"] = guesses + else: + supporting.append(file_data) + + files["supporting"] = supporting + write_upload_data(draft, current, current_step=current_step) + return current diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 421b3e7..e03ffe2 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -273,6 +273,7 @@ def read_case_data(draft: FilingDraft | None) -> dict[str, Any]: _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, "amount_in_controversy", draft.amount_in_controversy) _put(data, "reason_for_name_change", draft.name_change_reason) _put(data, "reason_for_change", draft.name_change_reason) @@ -504,6 +505,7 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: "selected_payment_account_name": draft.selected_payment_account_name, "optional_services": draft.optional_services, "extracted_guesses": draft.extracted_guesses, + "document_checklist_acknowledged": draft.document_checklist_acknowledged, "supplemental_fields": draft.supplemental_fields, "document_count": FilingDocument.objects.filter(draft=draft).count(), "party_count": FilingParty.objects.filter(draft=draft).count(), diff --git a/efile_app/efile/services/efsp_errors.py b/efile_app/efile/services/efsp_errors.py index 9e36c6f..818a023 100644 --- a/efile_app/efile/services/efsp_errors.py +++ b/efile_app/efile/services/efsp_errors.py @@ -13,10 +13,20 @@ 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. + +Some rejections instead arrive as a single free-text sentence (a "Malformed +Interview" body, or a plain ``error`` string) written for a developer reading +the proxy's logs, not a filer. ``_KNOWN_MESSAGE_HINTS`` recognizes the ones +that come up in practice -- lifted from the literal strings the proxy raises, +in ~/EfileProxyServer (see e.g. ``Ecf4Filer.java`` and +``FilingInformationDocassembleJacksonDeserializer.java``) -- and appends a +sentence saying what to actually do about it. Unrecognized messages still pass +through unchanged rather than being hidden. """ import json import re +from collections.abc import Callable # Field names the EFSP uses, in the words the UI uses for them. _FIELD_LABELS = { @@ -37,6 +47,66 @@ _MAX_RAW_BODY = 300 +# (pattern, hint builder) pairs checked in order against a free-text EFSP +# message; the first match wins. Each hint tells the filer what to actually do, +# not just what went wrong. Patterns are deliberately specific substrings of +# the proxy's own wording so an unrelated message never matches by accident. +_KNOWN_MESSAGE_HINTS: list[tuple[re.Pattern, Callable[[re.Match], str]]] = [ + ( + re.compile(r"doesn't allow subsequent filing into non-indexed cases", re.IGNORECASE), + lambda m: ( + "Go back to the case details step: choose New case and remove the case number, " + "or choose Existing case and look up the case instead." + ), + ), + ( + re.compile(r"needs docket number, but not present", re.IGNORECASE), + lambda m: "Go back to the case details step and provide the court's case number for this existing case.", + ), + ( + re.compile(r"Document .*? is too big! Must be max (\d+)", re.IGNORECASE), + lambda m: ( + f"One of your PDFs is over the court's {int(m.group(1)):,}-byte limit. " + "Compress it or split it into smaller files, then re-upload." + ), + ), + ( + re.compile(r"All Documents combined are too big! Must be max\s*(\d+)", re.IGNORECASE), + lambda m: ( + f"Your documents add up to more than the court's {int(m.group(1)):,}-byte combined limit. " + "Remove or compress some documents and try again." + ), + ), + ( + re.compile(r"Need a filing type! FilingTypes are empty", re.IGNORECASE), + lambda m: ( + "This court doesn't offer any filing types for that case category and case type " + "together. Go back and double-check the case category, case type, and whether " + "this is a new or existing case." + ), + ), + ( + re.compile(r"Amount in controversy required", re.IGNORECASE), + lambda m: ( + "This case type requires an amount in controversy, which this tool doesn't collect yet. " + "Contact the court about filing this case another way." + ), + ), +] + + +def _actionable_hint(message: str) -> str | None: + for pattern, build_hint in _KNOWN_MESSAGE_HINTS: + match = pattern.search(message) + if match: + return build_hint(match) + return None + + +def _with_hint(message: str) -> str: + hint = _actionable_hint(message) + return f"{message} {hint}" if hint else message + def describe_efsp_error(response) -> str: """Describe why the EFSP refused ``response``'s request. @@ -70,7 +140,15 @@ def describe_efsp_error(response) -> str: validation_errors = body.get("validation_errors") or body.get("errors") if validation_errors: message += f" - Validation errors: {validation_errors}" - return message + return _with_hint(message) + + # "Malformed Interview" errors (e.g. a docket number on a case the court has + # no record of) arrive as {"type": ..., "description": ...} instead. + description = body.get("description") + if description: + error_type = str(body.get("type") or "").strip() + message = f"{error_type}: {description}" if error_type else str(description) + return _with_hint(message) return f"the court's filing service returned status {response.status_code}" diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 53d3e88..82c0f9d 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -361,6 +361,64 @@ margin-top: 0.3rem; } +.review-field>span { + color: #263c58; + display: block; + font-weight: 700; + margin-bottom: 0.35rem; +} + +.review-field em { + color: #778397; + font-size: 0.8rem; + font-style: normal; + font-weight: 500; +} + +.review-field__hint { + color: #758196; + display: block; + margin-top: 0.3rem; + min-height: 1.1em; +} + +.review-field__display { + align-items: center; + background: #f3faf5; + border: 1px solid #cde8d5; + border-radius: 8px; + display: flex; + gap: 0.6rem; + justify-content: space-between; + padding: 0.6rem 0.8rem; +} + +.review-field__found { + align-items: center; + color: #16703a; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.review-field__found i { + flex-shrink: 0; +} + +.review-field__found strong { + color: #1d2a3d; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-field__edit { + flex-shrink: 0; + font-weight: 700; + padding: 0; + text-decoration: none; +} + .path-confirmation { background: #f7f9fc; border: 1px solid #dfe5ed; @@ -516,6 +574,232 @@ margin: 0.25rem 0 0; } +.checklist-guidance { + align-items: flex-start; + background: #eef5ff; + border-left: 4px solid #3974ba; + border-radius: 8px; + color: #294565; + display: flex; + gap: 0.8rem; + margin: 1.5rem 0; + padding: 1rem; +} + +.checklist-guidance i { + color: #3974ba; + margin-top: 0.2rem; +} + +.checklist-guidance p { + margin: 0.2rem 0 0; +} + +.checklist-files { + border: 1px solid #dce3ec; + border-radius: 12px; + overflow: hidden; +} + +.checklist-file { + align-items: center; + display: grid; + gap: 0.8rem; + grid-template-columns: auto auto 1fr auto; + padding: 0.9rem 1rem; +} + +.checklist-file+.checklist-file { + border-top: 1px solid #e4e9f0; +} + +.checklist-file__check { + align-items: center; + background: #e2f5e9; + border-radius: 50%; + color: #18733d; + display: flex; + font-size: 0.7rem; + height: 24px; + justify-content: center; + width: 24px; +} + +.checklist-file__icon { + color: #b23b3b; + font-size: 1.35rem; +} + +.checklist-file small, +.checklist-confirmation small { + color: #6e7b8f; + display: block; +} + +.add-missing-toggle { + background: none; + border: 0; + color: #2c5aa0; + font-weight: 700; + margin: 1rem 0; + padding: 0.4rem 0; +} + +.missing-document-state { + background: #f7f9fc; + border: 1px dashed #bac7d6; + border-radius: 10px; + margin-bottom: 1.2rem; + padding: 1.2rem; +} + +.checklist-confirmation { + align-items: flex-start; + background: #fffaf0; + border: 1px solid #ead8ad; + border-radius: 10px; + display: flex; + gap: 0.8rem; + margin-top: 1.5rem; + padding: 1rem; +} + +.organize-list { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.main-document-choice { + background: #f6f9fd; + border: 1px solid #dce3ec; + border-radius: 12px; + margin-top: 1.5rem; + padding: 1rem; +} + +.main-document-choice legend { + color: #263c58; + float: none; + font-size: 1.05rem; + font-weight: 750; + margin: 0; + width: auto; +} + +.main-document-choice>p { + color: #68768a; + margin: 0.25rem 0 0.8rem; +} + +.main-document-choice small { + color: #6d798d; + display: block; +} + +.organize-card { + border: 1px solid #dce3ec; + border-radius: 12px; + overflow: hidden; +} + +.organize-card__header { + align-items: center; + background: #f6f9fd; + display: grid; + gap: 0.8rem; + grid-template-columns: auto 1fr auto; + padding: 0.9rem 1rem; +} + +.organize-card__icon { + color: #b23b3b; + font-size: 1.4rem; +} + +.organize-card__header strong, +.organize-card__position { + display: block; +} + +.organize-card__position { + color: #66758a; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.organize-card__fields { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 1rem; +} + +.reorder-buttons { + display: flex; + gap: 0.35rem; +} + +.certified-copy-details { + border-top: 1px solid #e3e8ef; + padding: 0.8rem 1rem 1rem; +} + +.certified-copy-details summary { + color: #38567a; + cursor: pointer; + font-weight: 700; +} + +.optional-services-list { + display: grid; + gap: 0.2rem; + margin-top: 0.8rem; +} + +.optional-services-list small { + color: #758196; +} + +.optional-service-description { + color: #758196; + margin: -0.35rem 0 0.35rem 1.6rem; +} + +.optional-services-toggle { + font-size: 0.85rem; + font-weight: 700; + justify-self: start; + padding: 0.2rem 0; + text-decoration: none; +} + +.compact-choice-field legend { + color: #263c58; + float: none; + font-size: 1rem; + font-weight: 700; + margin-bottom: 0.35rem; + width: auto; +} + +.compact-choice-list { + display: grid; + gap: 0.45rem; +} + +.compact-choice-list label { + align-items: center; + background: #f7f9fc; + border: 1px solid #dce3ec; + border-radius: 8px; + display: flex; + gap: 0.65rem; + padding: 0.6rem 0.75rem; +} + @media (max-width: 700px) { .review-grid { grid-template-columns: 1fr; @@ -541,4 +825,16 @@ .status-pill { grid-column: 2; } + + .checklist-file { + grid-template-columns: auto auto 1fr; + } + + .checklist-file .status-pill { + grid-column: 3; + } + + .organize-card__fields { + grid-template-columns: 1fr; + } } \ No newline at end of file diff --git a/efile_app/efile/static/js/document-checklist.js b/efile_app/efile/static/js/document-checklist.js new file mode 100644 index 0000000..fbe6bc3 --- /dev/null +++ b/efile_app/efile/static/js/document-checklist.js @@ -0,0 +1,35 @@ +(function() { + const form = document.getElementById("checklist-upload-form"); + if (!form) return; + const state = document.getElementById("checklist-upload-state"); + const errorBox = document.getElementById("checklist-upload-error"); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + state.hidden = false; + errorBox.hidden = true; + const button = form.querySelector('button[type="submit"]'); + button.disabled = true; + try { + const response = await fetch(window.location.href, { + method: "POST", + body: new FormData(form), + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + }, + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || "Could not add documents."); + window.location.reload(); + } catch (error) { + state.hidden = true; + errorBox.textContent = error.message; + errorBox.hidden = false; + button.disabled = false; + } + }); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js new file mode 100644 index 0000000..cad2c81 --- /dev/null +++ b/efile_app/efile/static/js/extraction-review.js @@ -0,0 +1,305 @@ +(function() { + const contextEl = document.getElementById("extraction-context"); + const form = document.getElementById("extraction-review-form"); + if (!contextEl || !form) return; + + const context = JSON.parse(contextEl.textContent); + const guesses = context.guesses || {}; + const errorBox = document.getElementById("extraction-review-error"); + + const fields = { + court: { + select: document.getElementById("court_code"), + nameInput: document.getElementById("court_name"), + guessKey: "court", + savedCode: context.court_code, + }, + case_category: { + select: document.getElementById("case_category_code"), + nameInput: document.getElementById("case_category_name"), + guessKey: "case category", + savedCode: context.case_category_code, + }, + case_type: { + select: document.getElementById("case_type_code"), + nameInput: document.getElementById("case_type_name"), + guessKey: "case type", + savedCode: context.case_type_code, + }, + filing_type: { + select: document.getElementById("filing_type_code"), + nameInput: document.getElementById("filing_type_name"), + guessKey: "filing type", + savedCode: context.filing_type_code, + }, + }; + + Object.entries(fields).forEach(([key, field]) => { + const root = field.select.closest(".review-field"); + field.display = root.querySelector(".review-field__display"); + field.input = root.querySelector(".review-field__input"); + field.valueEl = root.querySelector(".review-field__value"); + field.hint = root.querySelector(".review-field__hint"); + root.querySelector(".review-field__edit").addEventListener("click", () => setMode(key, "edit")); + }); + + function setMode(key, mode) { + const field = fields[key]; + field.display.hidden = mode !== "found"; + field.input.hidden = mode !== "edit"; + } + + function optionValue(item) { + return String(item.value ?? item.code ?? item.id ?? ""); + } + + function optionText(item) { + return (item.text || item.name || optionValue(item)).replace(/\s*\(Recommended\)$/, ""); + } + + async function getJson(url) { + const response = await fetch(url, { + headers: { + "X-CSRFToken": apiUtils.getCSRFToken() + } + }); + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || "Could not load choices from the court."); + } + return result.data || []; + } + + const PLACEHOLDERS = { + court: "Choose a court", + case_category: "Choose a court first", + case_type: "Choose a case category first", + filing_type: "Choose a case type first", + }; + + const DOWNSTREAM = { + court: ["case_category", "case_type", "filing_type"], + case_category: ["case_type", "filing_type"], + case_type: ["filing_type"], + filing_type: [], + }; + + function resetField(key, placeholder) { + const field = fields[key]; + field.select.innerHTML = ``; + field.select.disabled = true; + field.nameInput.value = ""; + field.hint.textContent = ""; + setMode(key, "edit"); + } + + function resetDownstream(key) { + DOWNSTREAM[key].forEach((child) => resetField(child, PLACEHOLDERS[child])); + } + + function existingCaseWire() { + const checked = form.querySelector('input[name="existing_case"]:checked'); + return checked && checked.value === "existing" ? "yes" : "no"; + } + + async function populate(key, options, placeholder) { + const field = fields[key]; + field.select.innerHTML = ``; + options.forEach((item) => { + const opt = new Option(optionText(item), optionValue(item)); + if (item.recommended || item.selected || item.default) opt.dataset.recommended = "true"; + field.select.add(opt); + }); + field.select.disabled = options.length === 0; + + const recommended = Array.from(field.select.options).find((o) => o.dataset.recommended); + const savedOption = field.savedCode ? + Array.from(field.select.options).find((o) => o.value === field.savedCode) : + null; + const chosen = savedOption || recommended; + + if (chosen) { + field.select.value = chosen.value; + field.nameInput.value = chosen.textContent; + field.valueEl.textContent = chosen.textContent; + setMode(key, "found"); + await ADVANCE[key](); + } else { + field.hint.textContent = guesses[field.guessKey] ? + "We found a hint in your document, but couldn't match it to an exact choice below." : + "We couldn't find this in your document."; + setMode(key, "edit"); + } + } + + async function loadCourts() { + const field = fields.court; + field.select.disabled = true; + field.select.innerHTML = ""; + try { + const options = await getJson(`/api/dropdowns/courts/?${new URLSearchParams({ + jurisdiction: context.jurisdiction, + guessed_court: guesses.court || "", + })}`); + await populate("court", options, PLACEHOLDERS.court); + } catch (error) { + resetField("court", PLACEHOLDERS.court); + field.select.disabled = false; + field.hint.textContent = error.message; + } + } + + async function loadCaseCategories() { + const courtCode = fields.court.select.value; + resetDownstream("case_category"); + if (!courtCode) { + resetField("case_category", PLACEHOLDERS.case_category); + return; + } + const field = fields.case_category; + field.select.disabled = true; + field.select.innerHTML = ""; + try { + const options = await getJson(`/api/dropdowns/case-categories/?${new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: courtCode, + guessed_case_category: guesses["case category"] || "", + })}`); + await populate("case_category", options, "Choose a case category"); + } catch (error) { + resetField("case_category", "Choose a case category"); + field.select.disabled = false; + field.hint.textContent = error.message; + } + } + + async function loadCaseTypes() { + const courtCode = fields.court.select.value; + const categoryCode = fields.case_category.select.value; + resetDownstream("case_type"); + if (!courtCode || !categoryCode) { + resetField("case_type", PLACEHOLDERS.case_type); + return; + } + const field = fields.case_type; + field.select.disabled = true; + field.select.innerHTML = ""; + try { + const options = await getJson(`/api/dropdowns/case-types/?${new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: courtCode, + parent: categoryCode, + guessed_case_type: guesses["case type"] || "", + })}`); + await populate("case_type", options, "Choose a case type"); + } catch (error) { + resetField("case_type", "Choose a case type"); + field.select.disabled = false; + field.hint.textContent = error.message; + } + } + + async function loadFilingTypes() { + const courtCode = fields.court.select.value; + const categoryCode = fields.case_category.select.value; + const typeCode = fields.case_type.select.value; + resetDownstream("filing_type"); + if (!courtCode || !categoryCode || !typeCode) { + resetField("filing_type", PLACEHOLDERS.filing_type); + return; + } + const field = fields.filing_type; + field.select.disabled = true; + field.select.innerHTML = ""; + try { + const options = await getJson(`/api/dropdowns/filing-types/?${new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: courtCode, + case_category: categoryCode, + case_type: typeCode, + existing_case: existingCaseWire(), + guessed_filing_type: guesses["filing type"] || "", + })}`); + await populate("filing_type", options, "Choose a filing type"); + } catch (error) { + resetField("filing_type", "Choose a filing type"); + field.select.disabled = false; + field.hint.textContent = error.message; + } + } + + const ADVANCE = { + court: loadCaseCategories, + case_category: loadCaseTypes, + case_type: loadFilingTypes, + filing_type: async () => {}, + }; + + fields.court.select.addEventListener("change", () => { + fields.court.nameInput.value = fields.court.select.selectedOptions[0]?.textContent || ""; + loadCaseCategories(); + }); + fields.case_category.select.addEventListener("change", () => { + fields.case_category.nameInput.value = fields.case_category.select.selectedOptions[0]?.textContent || ""; + loadCaseTypes(); + }); + fields.case_type.select.addEventListener("change", () => { + fields.case_type.nameInput.value = fields.case_type.select.selectedOptions[0]?.textContent || ""; + loadFilingTypes(); + }); + fields.filing_type.select.addEventListener("change", () => { + fields.filing_type.nameInput.value = fields.filing_type.select.selectedOptions[0]?.textContent || ""; + }); + + form.querySelectorAll('input[name="existing_case"]').forEach((radio) => { + radio.addEventListener("change", () => { + if (fields.case_type.select.value) loadFilingTypes(); + }); + }); + + // Tyler rejects a docket/case number on a new case ("doesn't allow + // subsequent filing into non-indexed cases"), so keep the field out of + // the way unless the filer is sure they have one. + const docketToggleWrap = document.getElementById("docket-number-toggle"); + const docketCheckbox = document.getElementById("has-docket-number"); + const docketInput = document.getElementById("docket_number"); + const docketHint = document.getElementById("docket-number-hint"); + + function updateDocketNumberVisibility() { + const existingCase = form.querySelector('input[name="existing_case"]:checked')?.value; + if (existingCase === "existing") { + docketToggleWrap.hidden = true; + docketInput.hidden = false; + docketHint.hidden = false; + return; + } + docketToggleWrap.hidden = false; + const show = docketCheckbox.checked; + docketInput.hidden = !show; + docketHint.hidden = !show; + if (!show) docketInput.value = ""; + } + + docketCheckbox.addEventListener("change", updateDocketNumberVisibility); + form.querySelectorAll('input[name="existing_case"]').forEach((radio) => { + radio.addEventListener("change", updateDocketNumberVisibility); + }); + if (docketInput.value.trim()) docketCheckbox.checked = true; + updateDocketNumberVisibility(); + + form.addEventListener("submit", (event) => { + const isNew = form.querySelector('input[name="existing_case"]:checked')?.value === "new"; + const missing = isNew && (!fields.court.select.value || !fields.case_category.select.value || !fields.case_type.select.value); + if (missing) { + event.preventDefault(); + errorBox.textContent = "Choose a court, case category, and case type from the lists to continue."; + errorBox.hidden = false; + errorBox.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + } + }); + + loadCourts(); +})(); \ No newline at end of file diff --git a/efile_app/efile/static/js/organize-documents.js b/efile_app/efile/static/js/organize-documents.js new file mode 100644 index 0000000..f319ecd --- /dev/null +++ b/efile_app/efile/static/js/organize-documents.js @@ -0,0 +1,376 @@ +(function() { + const form = document.getElementById("organize-documents-form"); + const contextElement = document.getElementById("organize-context"); + if (!form || !contextElement) return; + + const context = JSON.parse(contextElement.textContent); + const list = document.getElementById("organize-list"); + const errorBox = document.getElementById("organize-error"); + const cards = () => Array.from(list.querySelectorAll(".organize-card")); + let filingTypes = null; + + function optionValue(item) { + return String(item.value || item.code || item.id || ""); + } + + function optionText(item) { + return item.text || item.name || item.description || optionValue(item); + } + + function setOptions(select, options, savedValue, placeholder) { + select.innerHTML = ""; + if (!options.length) { + const option = new Option(placeholder, ""); + select.add(option); + select.disabled = true; + return; + } + select.add(new Option(placeholder, "")); + options.forEach((item) => { + const option = new Option(optionText(item), optionValue(item)); + // Some filing types require an amount in controversy; case_questions + // asks for it later, but only needs to if this is set on the chosen + // filing type for at least one document in the filing. + option.dataset.amountInControversyRequired = + String(item.amountincontroversy || "").toLowerCase() === "required"; + select.add(option); + }); + select.value = savedValue || ""; + select.disabled = false; + } + + function setRadioOptions(container, options, savedValue, fieldName, placeholder) { + container.innerHTML = ""; + if (!options.length) { + const message = document.createElement("small"); + message.textContent = placeholder; + container.appendChild(message); + return; + } + options.forEach((item, index) => { + const label = document.createElement("label"); + const input = document.createElement("input"); + input.className = "form-check-input"; + input.type = "radio"; + input.name = fieldName; + input.value = optionValue(item); + input.required = true; + input.checked = input.value === savedValue || (!savedValue && options.length === 1 && index === 0); + const text = document.createElement("span"); + text.textContent = optionText(item); + label.append(input, text); + container.appendChild(label); + }); + } + + async function getJson(url) { + const response = await fetch(url, { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": apiUtils.getCSRFToken(), + }, + }); + const result = await response.json(); + if (!response.ok || !result.success) { + throw new Error(result.error || "The court's document choices could not be loaded."); + } + return result.data || []; + } + + async function loadFilingTypes() { + if (filingTypes) return filingTypes; + const params = new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: context.court, + case_category: context.case_category, + case_type: context.case_type, + existing_case: context.existing_case, + guessed_filing_type: context.guessed_filing_type || "", + }); + filingTypes = await getJson(`/api/dropdowns/filing-types/?${params}`); + return filingTypes; + } + + async function loadDependentOptions(card, filingType) { + const documentType = card.querySelector(".document-type-options"); + const component = card.querySelector(".filing-component-options"); + const documentId = card.dataset.documentId; + if (!filingType) { + setRadioOptions(documentType, [], "", `document-type-${documentId}`, "Select a filing type first"); + setRadioOptions(component, [], "", `filing-component-${documentId}`, "Select a filing type first"); + return; + } + + documentType.innerHTML = "Loading choices…"; + component.innerHTML = "Loading choices…"; + const documentParams = new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: context.court, + parent: filingType, + }); + const componentParams = new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: context.court, + filing_type: filingType, + }); + const [documentTypes, components] = await Promise.all([ + getJson(`/api/dropdowns/document-types/?${documentParams}`), + getJson(`/api/get-filing-components/?${componentParams}`), + ]); + setRadioOptions( + documentType, + documentTypes, + card.dataset.documentType, + `document-type-${documentId}`, + "No confidentiality choices are available", + ); + + let savedComponent = card.dataset.filingComponent; + if (!savedComponent && components.length) { + const preferredWord = card.dataset.role === "lead" ? "lead" : "attachment"; + const preferred = components.find((item) => optionText(item).toLowerCase().includes(preferredWord)); + savedComponent = optionValue(preferred || components[0]); + } + setRadioOptions( + component, + components, + savedComponent, + `filing-component-${documentId}`, + "No document roles are available", + ); + } + + // Keywords for the optional services filers actually look for. Courts can + // list a dozen+ services (interpreter requests, sealed filings, various + // process-server fees...); surfacing all of them by default buries the + // handful people come here for, so only these -- plus anything marked + // required -- show before the "Show more options" toggle. + const COMMON_OPTIONAL_SERVICE_KEYWORDS = [ + "certified", + "copy", + "copies", + "expedit", + "priority", + "rush", + "courtesy", + ]; + + function isCommonOptionalService(name) { + const lower = (name || "").toLowerCase(); + return COMMON_OPTIONAL_SERVICE_KEYWORDS.some((keyword) => lower.includes(keyword)); + } + + function buildOptionalServiceLabel(service, saved) { + const code = String(service.code ?? service.id ?? ""); + const label = document.createElement("label"); + label.className = "form-check mb-2"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.className = "form-check-input optional-service"; + input.value = code; + input.checked = Boolean(service.required) || saved.has(code); + input.disabled = Boolean(service.required); + const span = document.createElement("span"); + span.className = "form-check-label"; + let text = service.name || service.text || code; + const fee = parseFloat(service.fee); + if (fee > 0) text += ` ($${fee.toFixed(2)})`; + span.textContent = text; + label.append(input, span); + return { + code, + label + }; + } + + function appendOptionalService(container, service, saved) { + const { + code, + label + } = buildOptionalServiceLabel(service, saved); + if (!code) return; + container.appendChild(label); + if (service.description) { + const description = document.createElement("small"); + description.className = "d-block optional-service-description"; + description.textContent = service.description; + container.appendChild(description); + } + } + + async function loadOptionalServices(card, filingType) { + const container = card.querySelector(".optional-services-list"); + if (!filingType) { + container.innerHTML = "Select a filing type first"; + return; + } + container.innerHTML = "Loading choices…"; + const params = new URLSearchParams({ + jurisdiction: context.jurisdiction, + court: context.court, + filing_type_id: filingType, + }); + let services; + try { + services = await getJson(`/api/dropdowns/optional-services/?${params}`); + } catch (error) { + container.innerHTML = ""; + return; + } + services = services.filter((service) => service.code ?? service.id); + if (!services.length) { + container.innerHTML = ""; + return; + } + + const saved = new Set((card.dataset.optionalServices || "").split(",").filter(Boolean)); + let primary = services.filter((service) => service.required || isCommonOptionalService(service.name)); + let rest = services.filter((service) => !primary.includes(service)); + if (!primary.length) { + primary = services.slice(0, 4); + rest = services.slice(4); + } + + container.innerHTML = ""; + primary.forEach((service) => appendOptionalService(container, service, saved)); + + if (rest.length) { + const moreContainer = document.createElement("div"); + moreContainer.hidden = true; + rest.forEach((service) => appendOptionalService(moreContainer, service, saved)); + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "btn btn-link optional-services-toggle"; + const showMoreText = interpolate(ngettext("Show %s more option", "Show %s more options", rest.length), [rest.length]); + toggle.textContent = showMoreText; + toggle.addEventListener("click", () => { + const wasExpanded = !moreContainer.hidden; + moreContainer.hidden = wasExpanded; + toggle.textContent = wasExpanded ? showMoreText : gettext("Show fewer options"); + }); + + container.append(toggle, moreContainer); + } + } + + async function initializeCard(card) { + const filingType = card.querySelector(".filing-type"); + setOptions(filingType, await loadFilingTypes(), card.dataset.filingType, "Choose a filing type"); + filingType.addEventListener("change", async () => { + card.dataset.documentType = ""; + card.dataset.filingComponent = ""; + try { + await Promise.all([ + loadDependentOptions(card, filingType.value), + loadOptionalServices(card, filingType.value), + ]); + } catch (error) { + showError(error.message); + } + }); + await Promise.all([ + loadDependentOptions(card, filingType.value), + loadOptionalServices(card, filingType.value), + ]); + + const checkbox = card.querySelector(".courtesy-copy-toggle"); + const emailWrap = card.querySelector(".courtesy-email-wrap"); + const email = card.querySelector(".courtesy-email"); + checkbox.addEventListener("change", () => { + emailWrap.hidden = !checkbox.checked; + email.required = checkbox.checked; + if (!checkbox.checked) email.value = ""; + }); + email.required = checkbox.checked; + } + + function updatePositions() { + const supporting = cards().filter((card) => card.dataset.role === "supporting"); + supporting.forEach((card, index) => { + card.querySelector(".organize-card__position").textContent = `Additional document ${index + 1}`; + card.querySelector(".move-up").disabled = index === 0; + card.querySelector(".move-down").disabled = index === supporting.length - 1; + }); + } + + list.addEventListener("click", (event) => { + const button = event.target.closest(".move-up, .move-down"); + if (!button) return; + const card = button.closest(".organize-card"); + const supporting = cards().filter((item) => item.dataset.role === "supporting"); + const index = supporting.indexOf(card); + if (button.classList.contains("move-up") && index > 0) { + list.insertBefore(card, supporting[index - 1]); + } else if (button.classList.contains("move-down") && index < supporting.length - 1) { + supporting[index + 1].after(card); + } + updatePositions(); + }); + + function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; + errorBox.scrollIntoView({ + behavior: "smooth", + block: "center" + }); + } + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + errorBox.hidden = true; + if (!form.reportValidity()) return; + const button = document.getElementById("save-document-details"); + button.disabled = true; + const documents = cards().map((card) => { + const filingType = card.querySelector(".filing-type"); + const documentType = card.querySelector('.document-type-options input:checked'); + const component = card.querySelector('.filing-component-options input:checked'); + const courtesyEmail = card.querySelector(".courtesy-copy-toggle").checked ? + card.querySelector(".courtesy-email").value : ""; + const requestedOptionalServices = Array.from(card.querySelectorAll(".optional-service:checked")) + .map((input) => input.value); + return { + id: Number(card.dataset.documentId), + name: card.querySelector(".document-name").value, + filing_type: filingType.value, + filing_type_name: filingType.selectedOptions[0]?.text || "", + document_type: documentType?.value || "", + document_type_name: documentType?.closest("label")?.innerText.trim() || "", + filing_component: component?.value || "", + filing_component_name: component?.closest("label")?.innerText.trim() || "", + courtesy_copy_email: courtesyEmail, + requested_optional_services: requestedOptionalServices, + requires_amount_in_controversy: filingType.selectedOptions[0]?.dataset.amountInControversyRequired === "true", + }; + }); + + try { + const response = await fetch(window.location.href, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": apiUtils.getCSRFToken(), + }, + body: JSON.stringify({ + documents, + main_document_id: Number(form.elements.namedItem("main_document").value), + return_to: context.return_to || "", + }), + }); + if (response.redirected) { + window.location.assign(response.url); + return; + } + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || "Could not save document details."); + window.location.assign(result.redirect_url); + } catch (error) { + showError(error.message); + button.disabled = false; + } + }); + + Promise.all(cards().map(initializeCard)).then(updatePositions).catch((error) => showError(error.message)); +})(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/document_checklist.html b/efile_app/efile/templates/efile/document_checklist.html new file mode 100644 index 0000000..76c6795 --- /dev/null +++ b/efile_app/efile/templates/efile/document_checklist.html @@ -0,0 +1,92 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Check your documents" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "Check documents" %}
+

{% translate "Do you have all your documents?" %}

+

+ {% translate "Review the files below. Add anything else you want the court to receive with this filing." %} +

+
+ +
+ {% translate "Before you continue" %} +

+ {% translate "Include every completed court form, exhibit, translation, or proposed order that belongs with this filing. This list cannot determine which legal forms your case requires." %} +

+
+
+
+ {% for document in documents %} +
+ + + + {{ document.name|default:document.original_filename }} + {% translate "Uploaded document" %} + + {% translate "Added" %} +
+ {% endfor %} +
+ +
+
+ {% csrf_token %} + + + + + + +
+
+
+ {% csrf_token %} + +
+ {% translate "Back" %} + +
+
+
+{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 6e6ebb5..fbbf4c9 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -1,4 +1,5 @@ {% extends "efile/workflow_base.html" %} +{% load static %} {% load i18n %} {% block title %} {% translate "Review what we found" %} @@ -6,44 +7,119 @@ {% block workflow_content %}
{% translate "Confirm case" %}
-

{% translate "Here's what we found" %}

-

- {% translate "We read your lead document to get a head start. Check these details and correct anything that is wrong." %} -

-
+ {% if has_guesses %} +

{% translate "Here's what we found" %}

+

+ {% translate "We read your lead document to get a head start. Review each item below and correct anything that's wrong." %} +

+ {% else %} +

{% translate "Tell us about your case" %}

+
+ {% translate "We couldn't automatically identify any case details from your document. Enter them below, or" %} + {% translate "go back and try a different document" %}. +
+ {% endif %} + {% csrf_token %} +
-
{% translate "Is this for a new or existing court case?" %} @@ -74,13 +150,23 @@

{% translate "Here's what we found" %}

+
- {% translate "Back" %} + {% if return_to == "review" %} + {% translate "Back to review" %} + {% else %} + {% translate "Back" %} + {% endif %}
+ {{ extraction_context|json_script:"extraction-context" }} {% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html new file mode 100644 index 0000000..f30af5b --- /dev/null +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -0,0 +1,160 @@ +{% extends "efile/workflow_base.html" %} +{% load static %} +{% load i18n %} +{% block title %} + {% translate "Organize your documents" %} +{% endblock title %} +{% block workflow_content %} +
+
{% translate "Organize documents" %}
+

{% translate "Organize your documents" %}

+

+ {% translate "Tell the court what each PDF is and whether it should be public or confidential. You can also rename and reorder additional documents." %} +

+ +
+ {% csrf_token %} +
+ {% translate "Which PDF is the main document?" %} + {% if documents|length == 1 %} +

{% translate "This is the only document in this filing, so it's the main document." %}

+ {% for document in documents %}{% endfor %} + {% else %} +

+ {% translate "Choose the document that starts or drives this filing, such as a petition. Upload order does not matter." %} +

+
+ {% for document in documents %} + + {% endfor %} +
+ {% endif %} +
+
+ {% for document in documents %} +
+
+ +
+ + {% if document.role == "lead" %} + {% translate "Lead document" %} + {% else %} + {% blocktranslate with number=forloop.counter0 %}Additional document {{ number }}{% endblocktranslate %} + {% endif %} + + {{ document.name|default:document.original_filename }} +
+ {% if document.role == "supporting" %} +
+ + +
+ {% else %} + {% translate "Main document" %} + {% endif %} +
+
+ + +
+ {% translate "Should this document be confidential?" %} +
+ {% translate "Select a filing type first" %} +
+
+
+ {% translate "Document role" %} +
+ {% translate "Select a filing type first" %} +
+ {% translate "The court uses this to place the PDF in the filing." %} +
+
+
+ {% translate "Certified copy and courtesy email" %} +
+ {% translate "Select a filing type first" %} +
+ + +
+
+ {% endfor %} +
+
+ {% if return_to == "review" %} + {% translate "Back to review" %} + {% else %} + {% translate "Back" %} + {% endif %} + +
+
+
+ {{ organize_context|json_script:"organize-context" }} +{% endblock workflow_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/efile_app/efile/tests/test_document_prep.py b/efile_app/efile/tests/test_document_prep.py new file mode 100644 index 0000000..75ae4d1 --- /dev/null +++ b/efile_app/efile/tests/test_document_prep.py @@ -0,0 +1,251 @@ +from unittest.mock import patch + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.drafts import read_upload_data +from efile.workflow import ExistingCase, WorkflowStepKey + + +def authorize(client, draft): + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session["jurisdiction"] = "illinois" + session.save() + + +@pytest.fixture +def document_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="document-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + workflow_version=2, + existing_case=ExistingCase.NEW, + court_code="cook:cd", + case_category_code="100", + case_type_code="200", + current_step=WorkflowStepKey.DOCUMENT_CHECKLIST, + ) + FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="petition.pdf", + ) + client.force_login(user) + authorize(client, draft) + return draft + + +@pytest.mark.django_db +def test_document_checklist_requires_acknowledgement(client, document_draft): + response = client.post(reverse("document_checklist", kwargs={"jurisdiction": "illinois"}), {}) + + document_draft.refresh_from_db() + assert response.status_code == 200 + assert document_draft.document_checklist_acknowledged is False + assert b"Confirm that you have added every document" in response.content + + +@pytest.mark.django_db +def test_document_checklist_continues_to_organize(client, document_draft): + response = client.post( + reverse("document_checklist", kwargs={"jurisdiction": "illinois"}), + {"documents_complete": "yes"}, + ) + + document_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + assert document_draft.document_checklist_acknowledged is True + assert document_draft.current_step == WorkflowStepKey.ORGANIZE_DOCUMENTS + + +@pytest.mark.django_db +def test_document_checklist_adds_missing_documents_inline(client, document_draft): + upload = SimpleUploadedFile("exhibit.pdf", b"%PDF exhibit", content_type="application/pdf") + + with patch("efile.views.document_checklist.upload_files") as upload_files: + response = client.post( + reverse("document_checklist", kwargs={"jurisdiction": "illinois"}), + {"action": "upload", "documents": [upload]}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + upload_files.assert_called_once() + assert upload_files.call_args.kwargs["current_step"] == WorkflowStepKey.DOCUMENT_CHECKLIST + + +@pytest.mark.django_db +def test_organize_requires_completed_checklist(client, document_draft): + response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 302 + assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) + + +@pytest.mark.django_db +def test_organize_redirects_when_court_is_missing(client, document_draft): + document_draft.court_code = "" + document_draft.document_checklist_acknowledged = True + document_draft.save(update_fields=["court_code", "document_checklist_acknowledged", "updated_at"]) + + response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 302 + assert response.url == reverse("extraction_review", kwargs={"jurisdiction": "illinois"}) + + +@pytest.mark.django_db +def test_organize_returns_to_review_when_edited_from_there(client, document_draft): + document_draft.document_checklist_acknowledged = True + document_draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) + lead = document_draft.documents.get(role=FilingDocument.Role.LEAD) + details = [ + { + "id": lead.pk, + "name": "Petition", + "filing_type": "petition", + "filing_type_name": "Petition", + "document_type": "public", + "document_type_name": "No (Public)", + "filing_component": "lead", + "filing_component_name": "Lead Document", + }, + ] + + response = client.post( + reverse("organize_documents", kwargs={"jurisdiction": "illinois"}), + {"documents": details, "main_document_id": lead.pk, "return_to": "review"}, + content_type="application/json", + ) + + document_draft.refresh_from_db() + assert response.status_code == 200 + assert response.json()["redirect_url"] == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert document_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_organize_shows_no_radio_choice_for_a_single_document(client, document_draft): + document_draft.document_checklist_acknowledged = True + document_draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) + lead = document_draft.documents.get(role=FilingDocument.Role.LEAD) + + response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + content = response.content.decode() + assert "only document in this filing" in content + assert 'type="radio"' not in content + assert f'name="main_document" value="{lead.pk}"' in content + + +@pytest.mark.django_db +def test_organize_shows_radio_choice_for_multiple_documents(client, document_draft): + document_draft.document_checklist_acknowledged = True + document_draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) + FilingDocument.objects.create( + draft=document_draft, + role=FilingDocument.Role.SUPPORTING, + sort_order=0, + name="exhibit.pdf", + ) + + response = client.get(reverse("organize_documents", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + content = response.content.decode() + assert "only document in this filing" not in content + assert content.count('type="radio"') == 2 + + +@pytest.mark.django_db +def test_organize_saves_details_and_supporting_order(client, document_draft): + first = FilingDocument.objects.create( + draft=document_draft, + role=FilingDocument.Role.SUPPORTING, + sort_order=0, + name="first.pdf", + ) + second = FilingDocument.objects.create( + draft=document_draft, + role=FilingDocument.Role.SUPPORTING, + sort_order=1, + name="second.pdf", + ) + lead = document_draft.documents.get(role=FilingDocument.Role.LEAD) + document_draft.document_checklist_acknowledged = True + document_draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) + details = [ + { + "id": lead.pk, + "name": "Petition for name change", + "filing_type": "petition", + "filing_type_name": "Petition", + "document_type": "public", + "document_type_name": "No (Public)", + "filing_component": "lead", + "filing_component_name": "Lead Document", + "courtesy_copy_email": "filer@example.com", + "requested_optional_services": ["certified"], + "requires_amount_in_controversy": True, + }, + { + "id": second.pk, + "name": "Exhibit B", + "filing_type": "exhibit", + "filing_type_name": "Exhibit", + "document_type": "sealed", + "document_type_name": "Yes (Confidential)", + "filing_component": "attachment", + "filing_component_name": "Attachments", + "courtesy_copy_email": "", + }, + { + "id": first.pk, + "name": "Exhibit A", + "filing_type": "exhibit", + "filing_type_name": "Exhibit", + "document_type": "public", + "document_type_name": "No (Public)", + "filing_component": "attachment", + "filing_component_name": "Attachments", + "courtesy_copy_email": "", + }, + ] + + response = client.post( + reverse("organize_documents", kwargs={"jurisdiction": "illinois"}), + {"documents": details, "main_document_id": second.pk}, + content_type="application/json", + ) + + document_draft.refresh_from_db() + lead.refresh_from_db() + assert response.status_code == 200 + assert response.json()["redirect_url"] == reverse("your_information", kwargs={"jurisdiction": "illinois"}) + assert document_draft.current_step == WorkflowStepKey.YOUR_INFORMATION + assert lead.role == FilingDocument.Role.SUPPORTING + assert lead.filing_type_code == "petition" + assert lead.courtesy_copy_email == "filer@example.com" + assert lead.requested_optional_services == ["certified"] + assert lead.filing_requires_amount_in_controversy is True + assert list( + document_draft.documents.filter(role=FilingDocument.Role.SUPPORTING) + .order_by("sort_order") + .values_list("pk", flat=True) + ) == [lead.pk, first.pk] + + second.refresh_from_db() + assert second.role == FilingDocument.Role.LEAD + + saved = read_upload_data(document_draft) + assert saved["lead_filing_component"] == "attachment" + assert saved["supporting_documents"][0]["filing_type"] == "petition" diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index 8da3135..01319c4 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -133,6 +133,17 @@ def test_case_data_round_trips_through_the_model(django_user_model): assert blob["other_address_city"] == "Chicago" +@pytest.mark.django_db +def test_amount_in_controversy_is_read_back_from_the_draft(django_user_model): + """case_questions saves this directly on the model (it's not config-driven, + so it doesn't go through write_case_data), but the frontend still reads it + out of the same case_data blob everything else does.""" + user = django_user_model.objects.create_user(username="amount-owner", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois", amount_in_controversy="12500.00") + + assert read_case_data(draft)["amount_in_controversy"] == "12500.00" + + @pytest.mark.django_db def test_supplemental_case_fields_round_trip(django_user_model): """Config-driven questionnaire answers survive a durable-draft round trip.""" diff --git a/efile_app/efile/tests/test_efsp_errors.py b/efile_app/efile/tests/test_efsp_errors.py index de7ea33..0408439 100644 --- a/efile_app/efile/tests/test_efsp_errors.py +++ b/efile_app/efile/tests/test_efsp_errors.py @@ -9,7 +9,7 @@ import pytest -from efile.services.efsp_errors import describe_efsp_error +from efile.services.efsp_errors import _actionable_hint, describe_efsp_error WRONG_FILING_TYPE = { "required_vars": [], @@ -111,6 +111,95 @@ def test_validation_errors_are_appended_to_a_plain_message(): assert "bad bundle" in message +def test_malformed_interview_description_is_surfaced(): + """The EFSP's "Malformed Interview" shape has no error/message/detail key, + only type + description -- without this, the filer only ever saw the bare + status code even though the body explains exactly what to fix.""" + body = { + "type": "Malformed Interview", + "description": ( + "Court adams doesn't allow subsequent filing into non-indexed cases. " + "If this case is in the court system, provide the Case tracking ID. " + "If it's not, don't provide the docket number." + ), + } + + message = describe_efsp_error(FakeResponse(500, body)) + + assert "Malformed Interview" in message + assert "non-indexed cases" in message + assert "500" not in message + # The known-message catalog (lifted from the EFSP source) should recognize + # this exact rejection and say what to do about it, not just repeat it. + assert "New case" in message + assert "Existing case" in message + + +@pytest.mark.parametrize( + ("message", "expected_snippet"), + [ + ( + "Court adams doesn't allow subsequent filing into non-indexed cases. If this case is " + "in the court system, provide the Case tracking ID. If it's not, don't provide the " + "docket number.", + "New case", + ), + ( + "Subsequent filing case type (12345) needs docket number, but not present", + "case number for this existing case", + ), + ( + "Document affidavit.pdf is too big! Must be max 10485760, is 20000000", + "10,485,760-byte limit", + ), + ( + "All Documents combined are too big! Must be max10485760, are 15000000", + "10,485,760-byte combined limit", + ), + ( + "Need a filing type! FilingTypes are empty, so CAT and TYPE are restricted", + "double-check the case category", + ), + ( + "ad danum amount, Amount in controversy required", + "doesn't collect yet", + ), + ], +) +def test_known_messages_get_an_actionable_hint(message, expected_snippet): + hint = _actionable_hint(message) + + assert hint is not None + assert expected_snippet in hint + + +def test_unrecognized_messages_get_no_hint(): + assert _actionable_hint("Something entirely new went wrong") is None + + +def test_plain_error_message_gets_its_hint_appended_too(): + body = {"error": "Subsequent filing case type (12345) needs docket number, but not present"} + + message = describe_efsp_error(FakeResponse(400, body)) + + assert message.startswith(body["error"]) + assert "case number for this existing case" in message + + +def test_hint_still_matches_once_the_type_prefix_is_prepended(): + """describe_efsp_error prepends "{type}: " to description bodies, so a hint + pattern anchored to the start of the raw message would never fire -- this + caught that exact bug for the "Document ... too big" pattern.""" + body = { + "type": "Malformed Interview", + "description": "Document affidavit.pdf is too big! Must be max 10485760, is 20000000", + } + + message = describe_efsp_error(FakeResponse(400, body)) + + assert "10,485,760-byte limit" in message + + def test_non_json_body_falls_back_to_the_status_and_text(): message = describe_efsp_error(FakeResponse(502, text="Bad Gateway")) diff --git a/efile_app/efile/tests/test_filing_types_amount_in_controversy.py b/efile_app/efile/tests/test_filing_types_amount_in_controversy.py new file mode 100644 index 0000000..d0e9a40 --- /dev/null +++ b/efile_app/efile/tests/test_filing_types_amount_in_controversy.py @@ -0,0 +1,69 @@ +"""The court's "amountincontroversy" flag on a filing type has to survive the +/api/dropdowns/filing-types/ round trip so organize_documents can tell whether +a chosen filing type requires an amount in controversy. +""" + +import pytest +from django.urls import reverse + +from efile.api.dropdown_views import prioritize_options +from efile.models import FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY + + +def test_prioritize_options_keeps_extra_fields_from_the_court(): + api_data = [ + {"code": "PET", "name": "Petition", "amountincontroversy": "Required", "fee": "50.00"}, + {"code": "ANS", "name": "Answer", "amountincontroversy": "NotApplicable"}, + ] + + options = prioritize_options(api_data, guessed=None) + + petition = next(opt for opt in options if opt["value"] == "PET") + answer = next(opt for opt in options if opt["value"] == "ANS") + assert petition["amountincontroversy"] == "Required" + assert petition["fee"] == "50.00" + assert answer["amountincontroversy"] == "NotApplicable" + + +class _FilingTypesResponse: + status_code = 200 + headers = {"Content-Type": "application/json"} + + @staticmethod + def json(): + return [ + {"code": "PET", "name": "Petition", "amountincontroversy": "Required"}, + {"code": "ANS", "name": "Answer", "amountincontroversy": "NotApplicable"}, + ] + + +@pytest.fixture +def draft_session(client, django_user_model): + user = django_user_model.objects.create_user(username="amount-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + return draft + + +@pytest.mark.django_db +def test_filing_types_endpoint_surfaces_the_amount_in_controversy_flag(client, draft_session, monkeypatch): + monkeypatch.setattr( + "efile.api.dropdown_views.requests.get", + lambda *args, **kwargs: _FilingTypesResponse(), + ) + + response = client.get( + reverse("api:filing_types"), + {"jurisdiction": "illinois", "court": "cook:cd1", "case_type": "200", "case_category": "100"}, + ) + + assert response.status_code == 200 + body = response.json() + petition = next(opt for opt in body["data"] if opt["value"] == "PET") + assert petition["amountincontroversy"] == "Required" diff --git a/efile_app/efile/tests/test_name_suffixes.py b/efile_app/efile/tests/test_name_suffixes.py new file mode 100644 index 0000000..5086205 --- /dev/null +++ b/efile_app/efile/tests/test_name_suffixes.py @@ -0,0 +1,53 @@ +"""Name suffix has to be one of the court's own codes, not free text.""" + +import pytest +from django.urls import reverse + +from efile.models import FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY + + +class _NameSuffixesResponse: + status_code = 200 + + @staticmethod + def json(): + return [{"name": "Jr.", "code": "JR"}, {"name": "Sr.", "code": "SR"}, {"name": "III", "code": "III"}] + + +@pytest.fixture +def draft_session(client, django_user_model): + user = django_user_model.objects.create_user(username="suffix-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "token"} + session.save() + return draft + + +@pytest.mark.django_db +def test_name_suffixes_proxies_the_courts_own_list(client, draft_session, monkeypatch): + monkeypatch.setattr( + "efile.api.dropdown_views.requests.get", + lambda *args, **kwargs: _NameSuffixesResponse(), + ) + + response = client.get(reverse("api:name_suffixes"), {"jurisdiction": "illinois", "court": "cook:cd1"}) + + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert {"value": "JR", "text": "Jr."} in body["data"] + + +@pytest.mark.django_db +def test_name_suffixes_requires_a_court(client, draft_session): + response = client.get(reverse("api:name_suffixes"), {"jurisdiction": "illinois"}) + + assert response.status_code == 400 + body = response.json() + assert body["success"] is False + assert "court" in body["error"].lower() diff --git a/efile_app/efile/tests/test_reorganized_start.py b/efile_app/efile/tests/test_reorganized_start.py index 6d6180b..e911378 100644 --- a/efile_app/efile/tests/test_reorganized_start.py +++ b/efile_app/efile/tests/test_reorganized_start.py @@ -1,3 +1,4 @@ +import re from unittest.mock import MagicMock, patch import pytest @@ -54,9 +55,9 @@ def test_upload_documents_persists_lead_supporting_and_guesses(client, reorganiz supporting = SimpleUploadedFile("exhibit.pdf", b"%PDF exhibit", content_type="application/pdf") with ( - patch("efile.views.upload_documents.S3UploadHandler", return_value=handler), + patch("efile.services.document_uploads.S3UploadHandler", return_value=handler), patch( - "efile.views.upload_documents._analyze_lead", + "efile.services.document_uploads._analyze_lead", return_value={"court name": "Cook County", "case type": "Name Change"}, ), ): @@ -121,8 +122,11 @@ def test_extraction_review_branches_new_case_to_checklist(client, reorganized_dr reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), { "existing_case": ExistingCase.NEW, + "court_code": "cook", "court_name": "Cook County Circuit Court", + "case_category_code": "MR", "case_category_name": "Miscellaneous Remedy", + "case_type_code": "NC", "case_type_name": "Name Change", }, ) @@ -131,10 +135,65 @@ def test_extraction_review_branches_new_case_to_checklist(client, reorganized_dr assert response.status_code == 302 assert response.url == reverse("document_checklist", kwargs={"jurisdiction": "illinois"}) assert reorganized_draft.existing_case == ExistingCase.NEW + assert reorganized_draft.court_code == "cook" + assert reorganized_draft.case_type_code == "NC" assert reorganized_draft.case_type_name == "Name Change" assert reorganized_draft.current_step == WorkflowStepKey.DOCUMENT_CHECKLIST +@pytest.mark.django_db +def test_extraction_review_returns_to_review_when_edited_from_there(client, reorganized_draft): + FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + ) + + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "existing_case": ExistingCase.NEW, + "court_code": "cook", + "court_name": "Cook County Circuit Court", + "case_category_code": "MR", + "case_category_name": "Miscellaneous Remedy", + "case_type_code": "NC", + "case_type_name": "Name Change", + "return_to": "review", + }, + ) + + reorganized_draft.refresh_from_db() + assert response.status_code == 302 + assert response.url == reverse("case_review", kwargs={"jurisdiction": "illinois"}) + assert reorganized_draft.current_step == WorkflowStepKey.REVIEW + + +@pytest.mark.django_db +def test_extraction_review_new_case_requires_matched_court_and_type(client, reorganized_draft): + FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + ) + + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "existing_case": ExistingCase.NEW, + "court_name": "Cook County Circuit Court", + "case_category_name": "Miscellaneous Remedy", + "case_type_name": "Name Change", + }, + ) + + reorganized_draft.refresh_from_db() + assert response.status_code == 200 + assert b"Choose a court, case category, and case type" in response.content + assert reorganized_draft.current_step == WorkflowStepKey.EXTRACTION_REVIEW + assert reorganized_draft.court_code == "" + + @pytest.mark.django_db def test_extraction_review_requires_a_case_path(client, reorganized_draft): FilingDocument.objects.create( @@ -153,10 +212,21 @@ def test_extraction_review_requires_a_case_path(client, reorganized_draft): @pytest.mark.django_db -def test_unmigrated_downstream_screen_bridges_to_legacy_flow(client, reorganized_draft): - response = client.get(reverse("document_checklist", kwargs={"jurisdiction": "illinois"})) +def test_extraction_review_hides_case_number_behind_a_checkbox(client, reorganized_draft): + """Tyler rejects a docket number on a new case, so it should not be + presented as a normal always-visible field -- see the "checked out + docket number on a new case" 500 that surfaced this.""" + FilingDocument.objects.create( + draft=reorganized_draft, + role=FilingDocument.Role.LEAD, + name="petition.pdf", + ) - reorganized_draft.refresh_from_db() - assert response.status_code == 302 - assert response.url == reverse("expert_form", kwargs={"jurisdiction": "illinois"}) - assert reorganized_draft.workflow_version == 1 + response = client.get(reverse("extraction_review", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 200 + content = response.content.decode() + assert 'id="has-docket-number"' in content + docket_input = re.search(r"]*id=\"docket_number\"[^>]*>", content) + assert docket_input is not None + assert "hidden" in docket_input.group() diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 6e40c1d..76e7dfb 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -7,6 +7,7 @@ from .views.case_lookup import case_lookup from .views.choose_jurisdiction import choose_jurisdiction from .views.confirmation import filing_confirmation +from .views.document_checklist import document_checklist from .views.draft_views import create_draft_view, get_current_draft_view from .views.expert_form import efile_expert_form from .views.extraction_review import extraction_review @@ -15,6 +16,7 @@ from .views.legacy_workflow import legacy_workflow_redirect from .views.login import efile_login, efile_logout, efile_password_reset from .views.options import efile_options +from .views.organize_documents import organize_documents from .views.payment import efile_payment from .views.register import efile_register from .views.review import case_review @@ -57,18 +59,8 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//extraction-review/", extraction_review, name="extraction_review"), path("jurisdiction//case-lookup/", case_lookup, name="case_lookup"), path("jurisdiction//case-confirmation/", case_confirmation, name="case_confirmation"), - path( - "jurisdiction//document-checklist/", - legacy_workflow_redirect, - {"destination": "document_checklist"}, - name="document_checklist", - ), - path( - "jurisdiction//organize-documents/", - legacy_workflow_redirect, - {"destination": "organize_documents"}, - name="organize_documents", - ), + path("jurisdiction//document-checklist/", document_checklist, name="document_checklist"), + path("jurisdiction//organize-documents/", organize_documents, name="organize_documents"), path( "jurisdiction//your-information/", legacy_workflow_redirect, diff --git a/efile_app/efile/views/document_checklist.py b/efile_app/efile/views/document_checklist.py new file mode 100644 index 0000000..0a04cd6 --- /dev/null +++ b/efile_app/efile/views/document_checklist.py @@ -0,0 +1,63 @@ +from django.contrib import messages +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDocument +from efile.services.current_drafts import ensure_current_draft +from efile.services.document_uploads import upload_files +from efile.services.drafts import draft_snapshot +from efile.workflow import WorkflowStepKey, get_step_url, get_workflow_context + + +@require_http_methods(["GET", "POST"]) +def document_checklist(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.DOCUMENT_CHECKLIST, + workflow_version=2, + ) + documents = FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at") + if not documents.exists(): + messages.error(request, "Upload at least one document before checking your filing.") + return redirect("upload_documents", jurisdiction=jurisdiction) + + if request.method == "POST" and request.POST.get("action") == "upload": + uploaded_files = request.FILES.getlist("documents") + if not uploaded_files: + return JsonResponse({"success": False, "error": "Choose at least one PDF to add."}, status=400) + try: + upload_files( + draft, + uploaded_files, + jurisdiction, + current_step=WorkflowStepKey.DOCUMENT_CHECKLIST, + ) + except ValueError as error: + return JsonResponse({"success": False, "error": str(error)}, status=400) + if draft.document_checklist_acknowledged: + draft.document_checklist_acknowledged = False + draft.save(update_fields=["document_checklist_acknowledged", "updated_at"]) + return JsonResponse({"success": True, "document_count": FilingDocument.objects.filter(draft=draft).count()}) + + if request.method == "POST": + if request.POST.get("documents_complete") != "yes": + messages.error(request, "Confirm that you have added every document you want to file.") + else: + draft.document_checklist_acknowledged = True + draft.current_step = WorkflowStepKey.ORGANIZE_DOCUMENTS + draft.save(update_fields=["document_checklist_acknowledged", "current_step", "updated_at"]) + return redirect(get_step_url(WorkflowStepKey.ORGANIZE_DOCUMENTS, jurisdiction)) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "documents": documents, + } + context.update(get_workflow_context(WorkflowStepKey.DOCUMENT_CHECKLIST, jurisdiction, draft)) + return render(request, "efile/document_checklist.html", context) diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 5eb227e..9beeed4 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -7,6 +7,7 @@ from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, write_case_data from efile.workflow import ( + RETURN_TO_REVIEW, ExistingCase, WorkflowStepKey, get_next_step, @@ -15,6 +16,17 @@ ) +def _set_lead_filing_type(draft, filing_type_code, filing_type_name): + if not filing_type_code: + return + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + if lead is None: + return + lead.filing_type_code = filing_type_code + lead.filing_type_name = filing_type_name + lead.save(update_fields=["filing_type_code", "filing_type_name", "updated_at"]) + + @require_http_methods(["GET", "POST"]) def extraction_review(request, jurisdiction): if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -32,34 +44,67 @@ def extraction_review(request, jurisdiction): if request.method == "POST": existing_case = request.POST.get("existing_case", draft.existing_case) + court_code = request.POST.get("court_code", "") + case_category_code = request.POST.get("case_category_code", "") + case_type_code = request.POST.get("case_type_code", "") + if existing_case not in {ExistingCase.NEW, ExistingCase.EXISTING}: messages.error(request, "Choose whether this is a new or existing court case to continue.") + elif existing_case == ExistingCase.NEW and not (court_code and case_category_code and case_type_code): + # Tyler's e-filing API only accepts exact court/category/type codes, so + # a new case can't proceed on free-text guesses -- unlike an existing + # case, which resolves these from the case lookup step instead. + messages.error(request, "Choose a court, case category, and case type from the lists to continue.") else: write_case_data( draft, { "existing_case": existing_case, + "court": court_code, "court_name": request.POST.get("court_name", ""), + "case_category": case_category_code, "case_category_name": request.POST.get("case_category_name", ""), + "case_type": case_type_code, "case_type_name": request.POST.get("case_type_name", ""), "docket_number": request.POST.get("docket_number", ""), }, current_step=WorkflowStepKey.EXTRACTION_REVIEW, ) + _set_lead_filing_type( + draft, + request.POST.get("filing_type_code", ""), + request.POST.get("filing_type_name", ""), + ) + if request.POST.get("return_to") == RETURN_TO_REVIEW: + write_case_data(draft, {}, current_step=WorkflowStepKey.REVIEW) + return redirect(get_step_url(WorkflowStepKey.REVIEW, jurisdiction)) next_step = get_next_step(WorkflowStepKey.EXTRACTION_REVIEW, draft) if next_step: write_case_data(draft, {}, current_step=next_step.key) return redirect(get_step_url(next_step.key, jurisdiction)) guesses = draft.extracted_guesses or {} + lead = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).first() + extraction_context = { + "jurisdiction": jurisdiction, + "guesses": guesses, + "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, + "filing_type_code": lead.filing_type_code if lead else "", + "filing_type_name": lead.filing_type_name if lead else "", + } context = { "is_logged_in": True, "filing_draft": draft_snapshot(draft), - "guesses": guesses, - "court_name": draft.court_name or guesses.get("court"), - "case_category_name": draft.case_category_name or guesses.get("case category"), - "case_type_name": draft.case_type_name or guesses.get("case type"), + "has_guesses": bool(guesses), "docket_number": draft.docket_number or guesses.get("docket number"), + "extraction_context": extraction_context, + "return_to": request.GET.get("return_to", ""), } context.update(get_workflow_context(WorkflowStepKey.EXTRACTION_REVIEW, jurisdiction, draft)) return render(request, "efile/extraction_review.html", context) diff --git a/efile_app/efile/views/organize_documents.py b/efile_app/efile/views/organize_documents.py new file mode 100644 index 0000000..1a5cb17 --- /dev/null +++ b/efile_app/efile/views/organize_documents.py @@ -0,0 +1,139 @@ +import json + +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.core.validators import EmailValidator +from django.db import transaction +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from efile.api.suffolk_api_views import get_tyler_token +from efile.models import FilingDocument +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot +from efile.workflow import RETURN_TO_REVIEW, ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context + + +@transaction.atomic +def _save_document_details(draft, document_details, main_document_id): + documents = {document.pk: document for document in FilingDocument.objects.select_for_update().filter(draft=draft)} + if {item.get("id") for item in document_details} != set(documents): + raise ValueError("The document list changed. Refresh the page and try again.") + if main_document_id not in documents: + raise ValueError("Choose the main document for this filing.") + + # Move every document out of the final ranges before changing the main + # document or order, so role/order swaps cannot trip the uniqueness rule. + for document in documents.values(): + FilingDocument.objects.filter(pk=document.pk).update(sort_order=1_000_000 + document.pk) + + for document in documents.values(): + document.role = FilingDocument.Role.LEAD if document.pk == main_document_id else FilingDocument.Role.SUPPORTING + + supporting_order = 0 + for item in document_details: + document = documents[item["id"]] + filing_type = str(item.get("filing_type") or "").strip() + document_type = str(item.get("document_type") or "").strip() + if not filing_type or not document_type: + raise ValueError(f"Choose a filing type and confidentiality setting for {document.name}.") + + document.name = str(item.get("name") or "").strip()[:255] or document.name + document.filing_type_code = filing_type + document.filing_type_name = str(item.get("filing_type_name") or "")[:255] + document.document_type_code = document_type + document.document_type_name = str(item.get("document_type_name") or "")[:255] + document.filing_component_code = str(item.get("filing_component") or "")[:100] + document.filing_component_name = str(item.get("filing_component_name") or "")[:255] + courtesy_copy_email = str(item.get("courtesy_copy_email") or "").strip()[:254] + if courtesy_copy_email: + try: + EmailValidator()(courtesy_copy_email) + except ValidationError as error: + raise ValueError(f"Enter a valid courtesy copy email address for {document.name}.") from error + document.courtesy_copy_email = courtesy_copy_email + optional_services = item.get("requested_optional_services") + document.requested_optional_services = ( + [str(code)[:100] for code in optional_services if code] if isinstance(optional_services, list) else [] + ) + document.filing_requires_amount_in_controversy = bool(item.get("requires_amount_in_controversy")) + if document.pk == main_document_id: + document.sort_order = 0 + else: + document.sort_order = supporting_order + supporting_order += 1 + document.save() + + +@require_http_methods(["GET", "POST"]) +def organize_documents(request, jurisdiction): + if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): + return redirect("efile_login", jurisdiction=jurisdiction) + + draft = ensure_current_draft( + request, + jurisdiction, + current_step=WorkflowStepKey.ORGANIZE_DOCUMENTS, + workflow_version=2, + ) + documents = FilingDocument.objects.filter(draft=draft).order_by("role", "sort_order", "created_at") + if not documents.exists(): + return redirect("upload_documents", jurisdiction=jurisdiction) + if not draft.document_checklist_acknowledged: + messages.info(request, "Check that you have all of your documents before organizing them.") + return redirect("document_checklist", jurisdiction=jurisdiction) + if not draft.court_code: + # Filing types can't be looked up without a court. Send the filer back + # to whichever step is responsible for setting one, instead of + # stranding them here with no way to recover. + fix_step = ( + WorkflowStepKey.CASE_LOOKUP + if draft.existing_case == ExistingCase.EXISTING + else WorkflowStepKey.EXTRACTION_REVIEW + ) + messages.error(request, "Confirm the court for this filing before organizing your documents.") + return redirect(get_step_url(fix_step, jurisdiction)) + + if request.method == "POST": + try: + data = json.loads(request.body) + details = data.get("documents") + if not isinstance(details, list): + raise ValueError("Document details are missing.") + try: + main_document_id = int(data.get("main_document_id")) + except (TypeError, ValueError) as error: + raise ValueError("Choose the main document for this filing.") from error + _save_document_details(draft, details, main_document_id) + except (json.JSONDecodeError, ValueError) as error: + return JsonResponse({"success": False, "error": str(error)}, status=400) + + return_to_review = data.get("return_to") == RETURN_TO_REVIEW + next_step = WorkflowStepKey.REVIEW if return_to_review else WorkflowStepKey.YOUR_INFORMATION + draft.current_step = next_step + draft.save(update_fields=["current_step", "updated_at"]) + return JsonResponse( + { + "success": True, + "redirect_url": get_step_url(next_step, jurisdiction), + } + ) + + context = { + "is_logged_in": True, + "filing_draft": draft_snapshot(draft), + "documents": documents, + "return_to": request.GET.get("return_to", ""), + "organize_context": { + "jurisdiction": jurisdiction, + "court": draft.court_code, + "case_category": draft.case_category_code, + "case_type": draft.case_type_code, + "existing_case": "yes" if draft.existing_case == ExistingCase.EXISTING else "no", + "guessed_filing_type": (draft.extracted_guesses or {}).get("filing type", ""), + "return_to": request.GET.get("return_to", ""), + }, + } + context.update(get_workflow_context(WorkflowStepKey.ORGANIZE_DOCUMENTS, jurisdiction, draft)) + return render(request, "efile/organize_documents.html", context) diff --git a/efile_app/efile/views/upload_documents.py b/efile_app/efile/views/upload_documents.py index 0af18cb..3d1b84b 100644 --- a/efile_app/efile/views/upload_documents.py +++ b/efile_app/efile/views/upload_documents.py @@ -1,6 +1,4 @@ import logging -import os -from tempfile import NamedTemporaryFile from django.http import JsonResponse from django.shortcuts import redirect, render @@ -9,91 +7,14 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.models import FilingDocument from efile.services.current_drafts import ensure_current_draft -from efile.services.drafts import draft_snapshot, read_upload_data, write_upload_data -from efile.utils.llms import LlmError, extract_fields_from_file +from efile.services.document_uploads import upload_files +from efile.services.drafts import draft_snapshot, read_upload_data from efile.utils.s3_upload_handler import S3UploadHandler -from efile.views.session_api import llm_fields, llm_hints from efile.workflow import WorkflowStepKey, get_step_url, get_workflow_context logger = logging.getLogger(__name__) -def _analyze_lead(uploaded_file, jurisdiction): - temp_path = None - try: - uploaded_file.seek(0) - with NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file: - for chunk in uploaded_file.chunks(): - temp_file.write(chunk) - temp_path = temp_file.name - return extract_fields_from_file( - temp_path, - llm_fields.get(jurisdiction, llm_fields["default"]), - llm_hint=llm_hints.get(jurisdiction, llm_hints["default"]), - ) - except LlmError: - logger.exception("Document extraction failed") - return {} - finally: - if temp_path: - try: - os.unlink(temp_path) - except OSError: - logger.warning("Could not remove extraction temp file %s", temp_path) - - -def _guess_payload(found_fields): - return { - "court": found_fields.get("court name"), - "filing type": found_fields.get("filing type"), - "case category": found_fields.get("case category"), - "case type": found_fields.get("case type"), - "docket number": found_fields.get("docket number") or found_fields.get("docker number"), - } - - -def _upload_files(draft, uploaded_files, jurisdiction): - handler = S3UploadHandler() - if not handler._ensure_initialized(): - raise ValueError("Document storage is not configured. Please try again later.") - - current = read_upload_data(draft) - files = current.setdefault("files", {}) - supporting = list(files.get("supporting", [])) - lead_file = None - - for uploaded_file in uploaded_files: - validation = handler.validate_file(uploaded_file, max_size_mb=10, allowed_types=[".pdf"]) - if not validation["valid"]: - raise ValueError(f"{uploaded_file.name}: {validation['error']}") - - is_lead = not files.get("lead") and lead_file is None - role = FilingDocument.Role.LEAD if is_lead else FilingDocument.Role.SUPPORTING - uploaded_file.seek(0) - result = handler.upload_file(uploaded_file, file_type=role) - if not result["success"]: - raise ValueError(result.get("error", f"Could not upload {uploaded_file.name}.")) - - file_data = { - "name": uploaded_file.name, - "size": uploaded_file.size, - "type": uploaded_file.content_type, - "url": handler.get_public_url(result["key"]), - "s3_key": result["key"], - } - if is_lead: - files["lead"] = file_data - lead_file = uploaded_file - else: - supporting.append(file_data) - - files["supporting"] = supporting - if lead_file is not None: - current["guesses"] = _guess_payload(_analyze_lead(lead_file, jurisdiction)) - write_upload_data(draft, current, current_step=WorkflowStepKey.UPLOAD_DOCUMENTS) - return current - - @require_http_methods(["GET", "POST"]) def upload_documents(request, jurisdiction): if not request.user.is_authenticated or not get_tyler_token(request, jurisdiction): @@ -142,8 +63,9 @@ def upload_documents(request, jurisdiction): if not uploaded_files: return JsonResponse({"success": False, "error": "Choose at least one PDF to upload."}, status=400) try: - upload_data = _upload_files(draft, uploaded_files, jurisdiction) + upload_data = upload_files(draft, uploaded_files, jurisdiction) except ValueError as error: + logger.exception("Upload failed for draft %s", draft.pk) return JsonResponse({"success": False, "error": str(error)}, status=400) return JsonResponse( { diff --git a/efile_app/efile/workflow.py b/efile_app/efile/workflow.py index 35e7a67..a193417 100644 --- a/efile_app/efile/workflow.py +++ b/efile_app/efile/workflow.py @@ -314,6 +314,26 @@ def get_step_url(step_key: WorkflowStepKey | str, jurisdiction: str) -> str: return reverse(get_step(step_key).url_name, kwargs={"jurisdiction": jurisdiction}) +RETURN_TO_REVIEW = "review" + + +def get_return_url(request: Any, jurisdiction: str, default_step: WorkflowStepKey | str) -> str: + """Resolve where a step's successful save should redirect to. + + Following "Edit" from the Review screen carries a ``return_to=review`` + marker through the step's form (a hidden field, or a query string for + JS-driven saves). Without it, saving always continues to ``default_step`` -- + the next screen in the linear workflow -- which otherwise forces filers to + click through every later screen again just to get back to Review, even + when only one earlier answer needed correcting. + """ + + return_to = request.POST.get("return_to") or request.GET.get("return_to") + if return_to == RETURN_TO_REVIEW: + return get_step_url(WorkflowStepKey.REVIEW, jurisdiction) + return get_step_url(default_step, jurisdiction) + + def get_resume_step_url(current_step: WorkflowStepKey | str | None, jurisdiction: str) -> str | None: if current_step is None: return None