diff --git a/docs/docs/admin/configuration.md b/docs/docs/admin/configuration.md index a5c5318..70ecb01 100644 --- a/docs/docs/admin/configuration.md +++ b/docs/docs/admin/configuration.md @@ -63,6 +63,17 @@ OPENAI_API_KEY="sk-..." ``` +## Court filing availability + +Court restrictions live in the jurisdiction YAML, not in environment variables. +Use `court_specific_requirements..filing_availability` to block a whole +court or selected human-readable category, case-type, and filing-type names. +The default is enabled; exact-name and explicit regex matching are supported. + +The [filing availability guide](../partners-courts/filing-availability.md) covers +the complete schema, examples, immediate filer warnings, server enforcement, +deployment, re-enabling, and troubleshooting. + ## Document preparation and previews Configure Gotenberg 8.16 or newer for Word conversion and PDF form flattening. diff --git a/docs/docs/partners-courts/filing-availability.md b/docs/docs/partners-courts/filing-availability.md new file mode 100644 index 0000000..67c9969 --- /dev/null +++ b/docs/docs/partners-courts/filing-availability.md @@ -0,0 +1,393 @@ +--- +id: filing-availability +title: Control filing availability +sidebar_label: Filing availability +sidebar_position: 3 +--- + +# Control filing availability + +Use court configuration to stop filings that LITEFile cannot support yet—for +example, when a court requires hearing scheduling that the e-filing service +does not provide. Filing is enabled by default. A restriction can cover a whole +court or selected case categories, case types, and filing types. + +Filers see a warning as soon as their selections match a restriction, before +continuing to the remaining questions. The restriction controls filing through +LITEFile; it does not change the court's rules or availability through other +filing providers. + +## Where to configure restrictions + +Edit the jurisdiction's existing YAML file: + +```text +efile_app/efile/static/config/states/.yaml +``` + +Add `filing_availability` inside the relevant court entry under +`court_specific_requirements`. It is a sibling of that court's `case_types`, +`contact`, and other settings. Preserve those existing settings, and merge +examples into the existing entry rather than repeating a YAML key. + +There is no environment variable or database migration for this feature. +`filing_availability` is not a top-level jurisdiction setting. + +### Court keys and type names + +These identify two different things: + +| Value | What to use | +| --- | --- | +| Court entry, such as `"cook:cvd1"` | The court route key used by the e-filing API and the existing court configuration. | +| `case_categories`, `case_types`, `filing_types` | The human-readable names from the court's choice lists, such as `"Small Claims"`, `"Contract"`, or `"Motion"`. | + +Tyler's numeric type IDs can change. Do not use those IDs in availability +selectors. Do not use document-checklist keys such as `name_change` unless +that is also the court's actual displayed name. Use the name shown for the +selected court, without LITEFile's recommendation marker (`*`). Verify names in +the environment where you will deploy: test and production choices can differ. + +The same name may appear in several categories. Combine category and type +selectors when you need to distinguish those cases. A change to a numeric ID +does not require changing a name rule; a change to the human-readable name may. + +## Configuration reference + +All fields are optional. Omit `filing_availability` when no restriction is needed. + +| Field | Type and default | Behavior | +| --- | --- | --- | +| `enabled` | Boolean; enabled unless explicitly `false` | `false` blocks every filing for this court. Use the YAML boolean `false`, not the string `"false"`. `true` does not cancel matching rules. | +| `message` | Plain-text string; built-in message if absent or empty | Default explanation for this court's restrictions. A message alone does not disable filing. | +| `rules` | List; empty by default | Each matching rule blocks filing. Rules are evaluated in list order. | +| `rules[].case_categories` | List of name matchers | Match the selected case category. | +| `rules[].case_types` | List of name matchers | Match the selected case type. | +| `rules[].filing_types` | List of name matchers | Match a filing type on any document in the envelope, including supporting documents. | +| `rules[].message` | Plain-text string; court message if absent or empty | Explanation for this particular restriction. | + +A **name matcher** is either a literal string or a mapping with a `regex` string. +Selectors must be lists, even when there is only one matcher: + +```yaml +case_types: ["Contract"] +filing_types: + - "Motion" + - regex: '(?i)motion to .+' +``` + +These rules apply to both new and existing cases. There is no rule field for +filing phase, date range, or whether an individual filing needs a hearing. Only +configure a combination you intend to block in full; the rule does not determine +hearing requirements automatically. + +### Exact names and regular expressions + +A plain string matches the entire name, case-sensitively. `"Motion"` does not +match `"motion"`, `"Motion to dismiss"`, or `"Notice of Motion"`. Parentheses, +periods, and other punctuation in a plain string are literal. + +LITEFile removes surrounding whitespace from the selected name. It does not +collapse internal spaces, change capitalization, or trim the configured literal. + +A `regex` matcher uses Python `re.fullmatch`, also case-sensitive by default. +Use `(?i)` for case-insensitive matching and `.*` when you intentionally want +to match additional text. Single-quote YAML regexes so backslashes remain literal. + +| Matcher | Matches | Does not match | +| --- | --- | --- | +| `"Motion (Other)"` | `Motion (Other)` | `Motion Other` | +| `{regex: '(?i)motion'}` | `Motion`, `MOTION` | `Motion to dismiss` | +| `{regex: '(?i)motion to .+'}` | `Motion to dismiss` | `Notice of Motion` | +| `{regex: '(?i).*hearing.*'}` | `Notice of hearing` | `Complaint` | + +An invalid regex raises a configuration error when evaluated. It does not +silently become a literal or an allow rule. Check patterns before deployment. + +### Combining selectors + +- Match **any entry** within one selector list. +- Match **every selector present** in one rule. +- Match **any rule** to block filing. +- A missing or not-yet-selected name does not match. A rule with no recognized + selectors, or an empty list for any selector it includes, never matches. + +For example, this rule blocks Motion or Notice of hearing only for Contract +cases in Small Claims: + +```yaml +case_categories: ["Small Claims"] +case_types: ["Contract"] +filing_types: ["Motion", "Notice of hearing"] +``` + +A Complaint in the same case would not match. If its envelope also includes a +supporting document with filing type Motion, the whole envelope is blocked. +To block all Contract cases **or** all Motions, use two separate rules instead. + +## Examples + +All type names below are illustrative. Confirm the affected names and court +requirements before enabling a restriction. + +### Disable a whole court + +The smallest restriction uses the built-in message: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + enabled: false +``` + +To provide a court-specific explanation, add `message`: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + enabled: false + message: >- + LITEFile cannot submit filings to this court yet. Contact the court + clerk to ask how to file. +``` + +### Disable a case type in Cook County + +This example blocks the human-readable case type Contract in Cook County's +Municipal Civil Division and explains the hearing-scheduling limitation: + +```yaml +court_specific_requirements: + "cook:cvd1": + filing_availability: + rules: + - case_types: ["Contract"] + message: >- + LITEFile cannot file this case type in Cook County yet because it + requires scheduling a hearing. Our e-filing service does not support + hearing scheduling yet. Contact the court clerk to ask how to file. +``` + +The example in `states/illinois.yaml` is commented out. It is not an active +restriction or a determination that all Contract filings require scheduling. +If only particular filing types require scheduling, narrow the rule by adding +`filing_types` to the same rule: + +```yaml +rules: + - case_types: ["Contract"] + filing_types: + - "Motion" + - regex: '(?i)motion to .+' + message: "LITEFile does not support hearing scheduling for this filing yet." +``` + +### Disable categories or filing types independently + +Each of these rules is independent. This example blocks every filing in Small +Claims and, in other categories, any envelope containing Notice of hearing: + +```yaml +court_specific_requirements: + "example:civil": + filing_availability: + message: "LITEFile cannot submit this filing to this court yet." + rules: + - case_categories: ["Small Claims"] + - filing_types: ["Notice of hearing"] + message: >- + This filing needs hearing scheduling, which LITEFile does not + support yet. Contact the court clerk to ask how to file. +``` + +### Apply a restriction across a county + +Use `"cook:*"` to cover every court route key starting with `cook:`: + +```yaml +court_specific_requirements: + "cook:*": + filing_availability: + rules: + - filing_types: ["Notice of hearing"] + message: "LITEFile does not support hearing scheduling for this filing yet." +``` + +The supported prefix is the text before the first colon, followed by `:*`. +`cook:*` matches `cook:cvd1` and `cook:law1`, but not `cook`, `cooksville:law1`, +or an unrelated court. This is not arbitrary wildcard or regex matching. + +Other jurisdictions can use this form if their court route keys share a county +prefix. Otherwise, repeat the rule for each affected court. Court entries are +scoped to their jurisdiction. Prefix matching applies only to availability; +other court-specific features such as checklist overrides still use exact keys. + +## Messages and precedence + +The built-in English message is: + +> LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file. + +Messages are plain text. HTML, Markdown links, and template placeholders are not +rendered or expanded. Custom YAML messages are displayed as written; they do not +use the separate `text` section's translation or Markdown system. Explain the +limitation and give the filer a practical next step without suggesting that they +choose an incorrect court or case type. + +Evaluation follows this order: + +1. Check the exact court entry. Within it, evaluate rules in list order. The + first match supplies its message, falling back to that entry's `message`, + then the built-in message. More detailed selectors do not automatically win; + place more specific explanations first. +2. If no exact-court rule matches and that court has `enabled: false`, block + with its court message or the built-in message. +3. If the exact entry did not block filing, repeat those checks for the county + prefix entry, when one applies. +4. If neither entry blocks filing, allow it. + +A matching rule can therefore supply a specific explanation even when its court +also has `enabled: false`. An exact-court block takes precedence over a prefix +block. An exact court's message does not become the fallback for a prefix rule. + +Restrictions are additive. `enabled: true` and `rules: []` on an exact court do +not exempt it from a county-prefix restriction. There is no allow-rule override. + +## What filers experience + +On the confirmation screen, changing a court, category, case type, or filing type +starts a live availability check. The warning appears beside the changed +selection. Continue is disabled while the check is pending or the selection is +blocked. Changing to an allowed selection clears the warning; late responses +for an older selection cannot replace the current result. + +Court selection during an existing-case lookup uses the same check. Once a case +lookup identifies its category and type, any restriction appears on the case +confirmation screen before the filer confirms it. Changing a document's filing +type on Organize documents also checks availability for the whole envelope. + +If the live check fails, Continue remains disabled and the filer is asked to +reload the page. A failed check is not treated as permission to proceed. + +Server checks also stop blocked choices on form submission, on the checklist +and review pages, and during final submission. Existing drafts are not exempt +from newly added restrictions. Drafts and documents are retained for correction; +removing the restriction lets an otherwise valid draft continue. + +At final submission, LITEFile resolves outgoing numeric IDs to current names +from the court API for configured type selectors and applies the same rules. +It does not trust browser-provided labels as proof that the filing is allowed. +If required names cannot be resolved, it stops submission with a retry message +before sending the filing. A whole-court restriction does not need a name lookup. + +## Deploy, verify, and re-enable + +1. Confirm the affected court route keys and the exact names offered in the + target environment. Decide whether to block the whole court, a category, + a case type, a filing type, or a combination. +2. Add the smallest appropriate restriction to the existing YAML entry. Keep + other court settings intact and write a plain-text explanation when the + built-in message is insufficient. +3. Test a matching selection, an allowed selection, a supporting-document match, + and any relevant county-prefix overlap in development or staging. Confirm + that the warning appears immediately and that correcting the choice clears + it. Test both new- and existing-case paths when they are affected. +4. Deploy the updated YAML through the normal application deployment process + so every application instance receives the same file. +5. Reopen an affected saved draft and verify the restriction. An already-open + browser page is not notified automatically; reload it or change a selection + to refresh the warning. Submission always rechecks availability. + +For an existing jurisdiction file, the configuration loader checks its modified +time and size and reloads changed settings on the next request. Editing its +availability rules does not itself require a worker restart. Adding an entirely +new jurisdiction file requires restarting the application so the loader discovers +it. Follow the normal restart behavior of your deployment system when shipping +files in a new image. + +To re-enable filing, remove the matching rule or the entire availability section. +For a whole-court block, remove `enabled: false` or set it to `true`, and also +remove any matching rules. Check both the exact court and county-prefix entries; +a remaining restriction in either still blocks filing. Verify an allowed result +after deploying the change. Numeric ID changes alone do not require rule edits. + +### Check a configured selection locally + +From `efile_app`, use the same matcher that the application uses. This example +reads the installed Illinois YAML without making a filing or calling the EFSP: + +```bash +uv run python manage.py shell -c 'from efile.services.filing_availability import filing_unavailable_message; print(filing_unavailable_message("illinois", "cook:cvd1", case_category="Small Claims", case_type="Contract", filing_types=["Motion"]))' +``` + +A nonempty result is the blocking message; an empty result means no configured +restriction matched. With the shipped Cook County example still commented out, +this command returns an empty result. This verifies configuration matching, not +the availability of all court services or the validity of a filing. + +Run the feature's regression tests with: + +```bash +uv run pytest -q efile/tests/test_filing_availability.py +``` + +`manage.py check` is not a complete availability-schema or regex validator. +Misspelled selector keys are ignored; a rule containing only unrecognized keys +never matches. Exercise the actual names and regexes before deployment. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| A rule never matches | Confirm the jurisdiction and court route key, YAML nesting, and selector spelling. Use a list of human-readable names, not numeric IDs or checklist keys. Compare capitalization, punctuation, and internal spaces. | +| A regex matches less than expected | Matching covers the whole name. Use `.*` for additional text and `(?i)` if capitalization should not matter. Use single-quoted YAML for patterns containing backslashes. | +| Too many filings are blocked | Multiple rules are alternatives. Put selectors in the same rule to require a combination. Look for `enabled: false`, broad regexes, and county-prefix rules. | +| The wrong explanation appears | First matching rule wins within an entry. Check exact-court precedence and message fallbacks; rules are not automatically ranked by specificity. | +| A court stays blocked after enabling it | `enabled: true` does not cancel its rules or a county-prefix restriction. Remove every applicable restriction and refresh the page. | +| The live check cannot finish | Check the availability endpoint and application logs, then reload. Invalid YAML or regexes need a configuration fix. Do not assume a failed check means the selection is allowed. | +| Submission cannot confirm availability | Verify the EFSP is reachable and the outgoing IDs still appear in its current choice lists. Re-select stale choices if necessary. A failed required name lookup stops submission. | + +## Live-check API and implementation + +The UI uses `GET /api/filing-availability/`. This is a read-only check of supplied +selections, not a reservation or authorization to submit a filing. + +| Query parameter | Meaning | +| --- | --- | +| `jurisdiction` | Installed jurisdiction key. Falls back to the session jurisdiction when omitted. | +| `court` | Court route key. | +| `case_category_name` | Selected human-readable category name, if known. | +| `case_type_name` | Selected human-readable case-type name, if known. | +| `filing_type_name` | Selected human-readable filing-type name. Repeat the parameter for multiple documents. | + +For example, using a local development server: + +```bash +curl --get 'http://127.0.0.1:8000/api/filing-availability/' \ + --data-urlencode 'jurisdiction=illinois' \ + --data-urlencode 'court=cook:cvd1' \ + --data-urlencode 'case_type_name=Contract' \ + --data-urlencode 'filing_type_name=Motion' \ + --data-urlencode 'filing_type_name=Exhibit' +``` + +A successful check returns HTTP 200 even when filing is blocked: + +```json +{"success": true, "available": false, "message": "The configured blocking message."} +``` + +An allowed result has `available: true` and `message: ""`. Responses use +`Cache-Control: no-store`. An unknown jurisdiction returns HTTP 400. Missing +names do not match type selectors, so an allowed result for partial choices +does not guarantee that later choices will be allowed. Browser-provided names +are advisory; the final submission check resolves names independently. + +The shared matcher and final name resolution live in +`efile_app/efile/services/filing_availability.py`. The live endpoint is in +`efile_app/efile/api/filing_availability.py`, and the shared browser controller +is `efile_app/efile/static/js/filing-availability.js`. Regression coverage lives +in `efile_app/efile/tests/test_filing_availability.py` and the live-availability +cases in `efile_app/tests/confirm-case-editing.spec.js`. diff --git a/docs/docs/partners-courts/index.md b/docs/docs/partners-courts/index.md index 6418070..43b2831 100644 --- a/docs/docs/partners-courts/index.md +++ b/docs/docs/partners-courts/index.md @@ -39,7 +39,7 @@ efile_app/efile/static/config/ ## Guides in this section -1. [**Jurisdiction & court configuration**](./jurisdiction-config.md): Setting up state metadata, logos, court codes, and clerk contact numbers. +1. [**Jurisdiction & court configuration**](./jurisdiction-config.md): Setting up state metadata, logos, court codes, and clerk contact numbers. See [Filing availability](./filing-availability.md) to disable filing for courts or selected category, case-type, and filing-type names, with immediate explanations for filers. 2. [**Document checklists & filing plans**](./document-checklists.md): Authoring plain-language document checklists with requirement levels and role-based conditions. 3. [**Customizing AI extraction & prompts**](./ai-customization.md): Fine-tuning LLM extraction prompts and field dictionaries for court documents. 4. [**Docassemble & AssemblyLine integration**](./interview-integration.md): Connecting automated interview workflows to e-file directly. diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index 4a101f8..4fbda33 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -392,3 +392,16 @@ three states are saved in `efile_app/efile/tests/fixtures/fee_code_samples.json` for regression tests. State configuration changes refresh the cached state settings on the next request. + +## Temporarily disable filing + +Add `filing_availability` under a court's `court_specific_requirements` entry to +block the whole court or selected categories, case types, and filing types. +Filing is enabled by default. Type selectors match human-readable names exactly +or with explicit regexes, independent of Tyler's numeric type IDs. Filers see +restrictions immediately after choosing an affected court or type. + +See [Control filing availability](./filing-availability.md) for the complete +field reference, Cook County hearing-scheduling example, county-prefix rules, +message precedence, deployment and re-enabling instructions, troubleshooting, +and live-check API. diff --git a/docs/sidebars.js b/docs/sidebars.js index 41bf3b5..d2c8856 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -25,6 +25,7 @@ const sidebars = { items: [ 'partners-courts/index', 'partners-courts/jurisdiction-config', + 'partners-courts/filing-availability', 'partners-courts/document-checklists', 'partners-courts/ai-customization', 'partners-courts/interview-integration', diff --git a/efile_app/efile/api/filing_availability.py b/efile_app/efile/api/filing_availability.py new file mode 100644 index 0000000..3721967 --- /dev/null +++ b/efile_app/efile/api/filing_availability.py @@ -0,0 +1,24 @@ +"""Read-only availability checks as a filer changes court and type selections.""" + +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods + +from efile.services.filing_availability import filing_unavailable_message +from efile.utils.config_loader import InvalidJurisdiction + + +@require_http_methods(["GET"]) +def get_filing_availability(request): + try: + message = filing_unavailable_message( + request.GET.get("jurisdiction") or request.session.get("jurisdiction"), + request.GET.get("court", ""), + case_category=request.GET.get("case_category_name", ""), + case_type=request.GET.get("case_type_name", ""), + filing_types=request.GET.getlist("filing_type_name"), + ) + except InvalidJurisdiction as error: + return JsonResponse({"success": False, "error": str(error)}, status=400) + response = JsonResponse({"success": True, "available": not bool(message), "message": message}) + response.headers["Cache-Control"] = "no-store" + return response diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index 56b18c1..0d373b3 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -26,6 +26,7 @@ get_optional_services, get_party_types, ) +from .filing_availability import get_filing_availability from .filing_views import get_filings, payment_fees from .payment_views import delete_payment_account, waiver_account from .suffolk_api_views import get_party_types_from_suffolk_api, lookup_case @@ -44,6 +45,7 @@ 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"), + path("filing-availability/", get_filing_availability, name="filing_availability"), # 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/services/filing_availability.py b/efile_app/efile/services/filing_availability.py new file mode 100644 index 0000000..6f66e82 --- /dev/null +++ b/efile_app/efile/services/filing_availability.py @@ -0,0 +1,194 @@ +"""Deployment-owned restrictions on filing through LITEFile, matched by human-readable type names.""" + +import re +from urllib.parse import quote, urlencode + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import render +from django.utils.translation import gettext as _ + +from efile.models import FilingDocument +from efile.services.efsp_payload import _EfspLookups +from efile.utils.config_loader import config_loader +from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context + +_SELECTORS = ("case_categories", "case_types", "filing_types") + + +def _court_availability(jurisdiction, court): + courts = config_loader.load_jurisdiction_config(jurisdiction).get("court_specific_requirements") or {} + keys = [court] + if ":" in court: + keys.append(court.split(":", 1)[0] + ":*") + availabilities = [(courts.get(key) or {}).get("filing_availability") or {} for key in keys] + for availability in availabilities: + _validate(availability) + return availabilities + + +def _validate(availability): + """Reject a malformed rule on every check, not only when a name reaches it. + + Otherwise a draft with blank saved names passes the early checks and the + rule first fails at submit, after the draft is claimed. + """ + for rule in availability.get("rules") or []: + for name in _SELECTORS: + matchers = rule.get(name) + if matchers is None: + continue + if not isinstance(matchers, list): + raise ImproperlyConfigured(f"Filing availability {name} must be a list of names.") + for matcher in matchers: + if isinstance(matcher, str): + continue + if not (isinstance(matcher, dict) and isinstance(matcher.get("regex"), str)): + raise ImproperlyConfigured("Availability selectors must contain names or {regex: pattern} entries.") + try: + re.compile(matcher["regex"]) + except re.error as error: + raise ImproperlyConfigured(f"Invalid filing availability regex: {matcher['regex']!r}") from error + + +def _selectors(availabilities): + """The selectors this court's rules actually use.""" + return { + name + for availability in availabilities + for rule in availability.get("rules") or [] + for name in _SELECTORS + if rule.get(name) + } + + +def _matches_name(name, matcher): + """Strings match exactly; explicit regex entries match the entire name.""" + if not name: + return False + return name == matcher if isinstance(matcher, str) else re.fullmatch(matcher["regex"], name) is not None + + +def filing_unavailable_message(jurisdiction, court, *, case_category="", case_type="", filing_types=()): + """Return a reason, or an empty string when no restriction matches. + + Exact court settings are checked before a county prefix (``cook:*``). + Rules are additive: an exact court cannot enable a county-wide restriction. + Values within a selector are alternatives; selectors within a rule must all + match. Category, case-type, and filing-type values are human-readable names, + never Tyler numeric IDs. Matching is case-sensitive; regex flags are explicit. + """ + return _unavailable_message( + _court_availability(jurisdiction, court), + case_category=case_category, + case_type=case_type, + filing_types=filing_types, + ) + + +def _unavailable_message(availabilities, *, case_category="", case_type="", filing_types=()): + selections = { + "case_categories": {str(case_category or "").strip()} - {""}, + "case_types": {str(case_type or "").strip()} - {""}, + "filing_types": {str(value).strip() for value in filing_types if value}, + } + for availability in availabilities: + fallback = availability.get("message") or _( + "LITEFile cannot submit this filing to this court right now. Contact the court clerk to ask how to file." + ) + # Specific explanations take precedence over the court's generic one. + for rule in availability.get("rules") or []: + # An empty selector (``case_types:``) matches nothing, like ``[]``. + selectors = [name for name in selections if name in rule] + if selectors and all( + any(_matches_name(value, matcher) for value in selections[name] for matcher in rule[name] or []) + for name in selectors + ): + return rule.get("message") or fallback + if availability.get("enabled") is False: + return fallback + return "" + + +def draft_unavailable_message(draft): + availabilities = _court_availability(draft.jurisdiction, draft.court_code) + # Most courts have no filing-type rule; skip the document query for them. + filing_types = ( + FilingDocument.objects.filter(draft=draft).values_list("filing_type_name", flat=True) + if "filing_types" in _selectors(availabilities) + else () + ) + return _unavailable_message( + availabilities, + case_category=draft.case_category_name, + case_type=draft.case_type_name, + filing_types=filing_types, + ) + + +def outgoing_unavailable_message(jurisdiction, court, case_data, payload): + """Resolve outgoing IDs to the court's names; never trust client labels at submit. + + Lookups are only needed for selectors configured for this court. A failed + lookup blocks submission, rather than letting an unknown name evade a rule. + """ + availabilities = _court_availability(jurisdiction, court) + selectors = _selectors(availabilities) + if not selectors: + return _unavailable_message(availabilities) + lookups = _EfspLookups() + base = f"{settings.EFSP_URL}/jurisdictions/{quote(jurisdiction, safe='')}/codes/courts/{quote(court, safe=':')}" + category = payload.get("efile_case_category") or case_data.get("case_category", "") + case_type = payload.get("efile_case_type") or case_data.get("case_type", "") + initial = not (payload.get("previous_case_id") or case_data.get("previous_case_id")) + + def resolve(path, codes): + choices = lookups.get(f"{base}/{path}") + if not isinstance(choices, list): + choices = [] + names = { + str(item["code"]): item["name"] + for item in choices + if isinstance(item, dict) and "code" in item and isinstance(item.get("name"), str) + } + if any(not names.get(str(code)) for code in codes): + raise ValueError(_("We could not confirm this filing's availability with the court. Try again later.")) + return [names[str(code)] for code in codes] + + category_name = resolve("categories", [category])[0] if "case_categories" in selectors else "" + type_name = ( + resolve("case_types/?" + urlencode({"category_id": category}), [case_type])[0] + if "case_types" in selectors + else "" + ) + filing_names = [] + if "filing_types" in selectors: + bundles = payload.get("al_court_bundle", []) + if not isinstance(bundles, list) or not all(isinstance(item, dict) for item in bundles): + raise ValueError(_("We could not read the filing types. Reload the review page and try again.")) + query = urlencode({"initial": str(initial).lower(), "category_id": category, "type_id": case_type}) + filing_names = resolve(f"filing_types/?{query}", [item.get("filing_type", "") for item in bundles]) + return _unavailable_message( + availabilities, + case_category=category_name, + case_type=type_name, + filing_types=filing_names, + ) + + +def unavailable_response(request, draft, message): + """Keep the draft intact and offer corrections without suggesting a false court.""" + case_step = ( + WorkflowStepKey.CASE_LOOKUP + if draft.existing_case == ExistingCase.EXISTING + else WorkflowStepKey.EXTRACTION_REVIEW + ) + context = { + "is_logged_in": True, + "draft": draft, + "availability_message": message, + "change_case_url": get_step_url(case_step, draft.jurisdiction), + "change_documents_url": get_step_url(WorkflowStepKey.ORGANIZE_DOCUMENTS, draft.jurisdiction), + } + context.update(get_workflow_context(draft.current_step, draft.jurisdiction, draft)) + return render(request, "efile/filing_unavailable.html", context, status=403) diff --git a/efile_app/efile/services/submission_errors.py b/efile_app/efile/services/submission_errors.py index fb77ab5..25341af 100644 --- a/efile_app/efile/services/submission_errors.py +++ b/efile_app/efile/services/submission_errors.py @@ -4,6 +4,7 @@ class SubmissionErrorCode: """Machine-readable codes for errors returned before filing submission.""" + FILING_UNAVAILABLE = "submission_filing_unavailable" CASE_DATA_MISSING = "submission_case_data_missing" UPLOAD_DATA_MISSING = "submission_upload_data_missing" EFILE_DATA_MISSING = "submission_efile_data_missing" @@ -15,6 +16,7 @@ class SubmissionErrorCode: PRE_SUBMIT_ERROR_CODES = frozenset( { + SubmissionErrorCode.FILING_UNAVAILABLE, SubmissionErrorCode.CASE_DATA_MISSING, SubmissionErrorCode.UPLOAD_DATA_MISSING, SubmissionErrorCode.EFILE_DATA_MISSING, diff --git a/efile_app/efile/static/config/states/illinois.yaml b/efile_app/efile/static/config/states/illinois.yaml index 8e74369..91f501b 100644 --- a/efile_app/efile/static/config/states/illinois.yaml +++ b/efile_app/efile/static/config/states/illinois.yaml @@ -527,6 +527,15 @@ court_specific_requirements: requirement: always "cook:cvd1": # Cook County Circuit Court - Municipal Civil Division + # Optional availability hook. Leave commented until affected names and + # hearing-scheduling requirements are confirmed against the target EFSP. + # filing_availability: + # rules: + # - case_types: ["Contract"] # Illustrative, not a live restriction. + # message: >- + # LITEFile cannot file this case type in Cook County yet because it + # requires scheduling a hearing. Our e-filing service does not support + # hearing scheduling yet. Contact the court clerk to ask how to file. case_types: eviction: documents: diff --git a/efile_app/efile/static/css/reorganized-flow.css b/efile_app/efile/static/css/reorganized-flow.css index 0885bb7..aab2c4a 100644 --- a/efile_app/efile/static/css/reorganized-flow.css +++ b/efile_app/efile/static/css/reorganized-flow.css @@ -692,6 +692,10 @@ margin-top: 0.3rem; } +.review-field { + min-width: 0; +} + .review-field>span { color: var(--text-heading); display: block; diff --git a/efile_app/efile/static/js/api-utils.js b/efile_app/efile/static/js/api-utils.js index 6c74c4b..fb274ab 100644 --- a/efile_app/efile/static/js/api-utils.js +++ b/efile_app/efile/static/js/api-utils.js @@ -43,6 +43,11 @@ class ApiUtils { return String(value || "").replace(/ \(Recommended\)$/, "").replace(/ \*$/, ""); } + // The court's name for a select's choice, or "" when nothing is chosen. + selectedOptionText(select) { + return select.value ? this.cleanOptionText(select.selectedOptions[0]?.textContent) : ""; + } + getCache() { try { const cached = localStorage.getItem('apiResponseCache'); diff --git a/efile_app/efile/static/js/case-lookup.js b/efile_app/efile/static/js/case-lookup.js index fea577c..1417416 100644 --- a/efile_app/efile/static/js/case-lookup.js +++ b/efile_app/efile/static/js/case-lookup.js @@ -11,6 +11,16 @@ const extractionHelp = document.getElementById("court-extraction-help"); const selectedCourtCode = JSON.parse(document.getElementById("selected-court-code").textContent || '""'); + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: apiUtils.getCurrentJurisdiction(), + court: courtSelect.value + }), + }); + courtSelect.addEventListener("change", () => availability.check(courtSelect.closest(".form-field"))); + async function mountCourtSelector() { const container = document.getElementById("court-selector"); if (!container || !window.courtSelector) return false; @@ -112,5 +122,5 @@ } }); - loadCourts(); + loadCourts().then(() => availability.check(courtSelect.closest(".form-field"))); })(); \ 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 index 390783f..27813f5 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -50,6 +50,18 @@ }, }; + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: context.jurisdiction, + court: fields.court.select.value, + case_category_name: apiUtils.selectedOptionText(fields.case_category.select), + case_type_name: apiUtils.selectedOptionText(fields.case_type.select), + filing_type_names: [apiUtils.selectedOptionText(fields.filing_type.select)], + }), + }); + const ORDER = ["court", "case_category", "case_type", "filing_type"]; const DOWNSTREAM = { @@ -174,7 +186,7 @@ const field = fields[key]; const option = field.select.selectedOptions[0]; const code = field.select.value; - const text = code ? apiUtils.cleanOptionText(option?.textContent) : ""; + const text = apiUtils.selectedOptionText(field.select); field.current = { code, text @@ -191,6 +203,7 @@ field.select.disabled = true; field.select.innerHTML = ``; field.root.setAttribute("aria-busy", "true"); + availability.check(); if (field.checking) { field.checking.textContent = gettext("Checking this is still offered…"); field.checking.hidden = false; @@ -214,6 +227,7 @@ field.nameInput.value = ""; field.hint.textContent = field.defaultHint; setMode(key, "edit"); + availability.check(); } function failField(key, message, placeholder) { @@ -357,6 +371,7 @@ setMode(key, "edit"); } if (token !== undefined && !isCurrent(key, token)) return; + availability.check(); await ADVANCE[key](); } @@ -510,6 +525,7 @@ ORDER.forEach((key) => { fields[key].select.addEventListener("change", () => { remember(key); + availability.check(fields[key].root); fields[key].hint.textContent = fields[key].defaultHint; ADVANCE[key](); }); @@ -707,5 +723,7 @@ if (changePath) changePath.addEventListener("click", () => openPathQuestion("")); if (applySuggestion) applySuggestion.addEventListener("click", () => openPathQuestion(applySuggestion.dataset.value)); - loadCourts(); + // The guard blocks until a check runs. A guided selector with no court + // chosen yet fires no change, so check once the courts are in place. + loadCourts().finally(() => availability.check()); })(); \ No newline at end of file diff --git a/efile_app/efile/static/js/filing-availability.js b/efile_app/efile/static/js/filing-availability.js new file mode 100644 index 0000000..99d86c7 --- /dev/null +++ b/efile_app/efile/static/js/filing-availability.js @@ -0,0 +1,95 @@ +(function() { + function mount({ + form, + notice, + selection + }) { + let generation = 0; + let blocked = true; + let controller; + const buttons = Array.from(form.querySelectorAll('button[type="submit"]')); + // Only the buttons this guard disabled are its to re-enable; a page + // disables its own while a save or lookup is in flight. + const held = new Set(); + + function show(message, unavailable) { + blocked = unavailable; + notice.textContent = message; + notice.hidden = !message; + buttons.forEach((button) => { + if (unavailable && !button.disabled) { + button.disabled = true; + held.add(button); + } else if (!unavailable && held.has(button)) { + button.disabled = false; + } + }); + if (!unavailable) held.clear(); + } + + let queued = false; + let pendingAnchor = null; + let lastQuery = null; + + // Pages call this from every step of a cascade. Calls made in the same + // turn collapse into one check, and an unchanged selection is not re-sent. + function check(anchor) { + if (anchor) pendingAnchor = anchor; + if (queued) return; + queued = true; + queueMicrotask(run); + } + + async function run() { + queued = false; + if (pendingAnchor) pendingAnchor.append(notice); + pendingAnchor = null; + const values = selection(); + const params = new URLSearchParams({ + jurisdiction: values.jurisdiction, + court: values.court || "", + case_category_name: values.case_category_name || "", + case_type_name: values.case_type_name || "", + }); + (values.filing_type_names || []).forEach((value) => params.append("filing_type_name", value)); + const query = params.toString(); + if (query === lastQuery) return; + lastQuery = query; + const token = ++generation; + controller?.abort(); + if (!values.court) { + show("", false); + return; + } + controller = new AbortController(); + show(gettext("Checking filing availability…"), true); + try { + const response = await fetch(`/api/filing-availability/?${query}`, { + signal: controller.signal + }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error("Availability check failed"); + if (token !== generation) return; + show(result.message, !result.available); + } catch (error) { + if (token !== generation || error.name === "AbortError") return; + lastQuery = null; + show(gettext("We could not check filing availability. Reload this page to try again."), true); + } + } + + // Also guard Enter-key submission while a check is pending or blocked. + form.addEventListener("submit", (event) => { + if (!blocked) return; + event.preventDefault(); + event.stopImmediatePropagation(); + notice.focus(); + }, true); + return { + check + }; + } + window.filingAvailability = { + mount + }; +})(); \ 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 index 1bd1f8f..a9b7a5f 100644 --- a/efile_app/efile/static/js/organize-documents.js +++ b/efile_app/efile/static/js/organize-documents.js @@ -32,6 +32,23 @@ // it -- see efile/utils/ui_text.py. const text = context.text || {}; let filingTypes = null; + const availability = window.filingAvailability.mount({ + form, + notice: document.getElementById("filing-availability-notice"), + selection: () => ({ + jurisdiction: context.jurisdiction, + court: context.court, + case_category_name: context.case_category_name, + case_type_name: context.case_type_name, + filing_type_names: cards().map((card) => { + const select = card.querySelector(".filing-type"); + // Until the court's list loads, the saved name is the selection. + if (!card.dataset.filingTypesLoaded) return card.dataset.filingTypeName || ""; + return apiUtils.selectedOptionText(select); + }), + }), + }); + availability.check(); function optionValue(item) { return String(item.value || item.code || item.id || ""); @@ -360,7 +377,9 @@ async function initializeCard(card) { const filingType = card.querySelector(".filing-type"); setOptions(filingType, await loadFilingTypes(), card.dataset.filingType, "Choose a filing type"); + card.dataset.filingTypesLoaded = "true"; filingType.addEventListener("change", async () => { + availability.check(filingType.closest(".form-field") || filingType.parentElement); card.dataset.documentType = ""; card.dataset.filingComponent = ""; try { @@ -485,5 +504,10 @@ } }); - Promise.all(cards().map(initializeCard)).then(updatePositions).catch((error) => showError(error.message)); + Promise.all(cards().map(initializeCard)) + .then(() => { + updatePositions(); + availability.check(); + }) + .catch((error) => showError(error.message)); })(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/case_confirmation.html b/efile_app/efile/templates/efile/case_confirmation.html index a872e3b..520cd2c 100644 --- a/efile_app/efile/templates/efile/case_confirmation.html +++ b/efile_app/efile/templates/efile/case_confirmation.html @@ -61,6 +61,7 @@

{% translate "Is this your court case?" %}

+ {% if availability_message %}{% endif %}
{% csrf_token %}
@@ -74,7 +75,11 @@

{% translate "Is this your court case?" %}

value="no"> {% translate "No, search again" %} -
diff --git a/efile_app/efile/templates/efile/case_lookup.html b/efile_app/efile/templates/efile/case_lookup.html index 699ff8f..d0cc3d0 100644 --- a/efile_app/efile/templates/efile/case_lookup.html +++ b/efile_app/efile/templates/efile/case_lookup.html @@ -20,6 +20,7 @@

{% translate "Find your court case" %}

{% endif %} {% csrf_token %} + {% include "efile/components/filing_availability_notice.html" %}
{% translate "Court" %} @@ -68,6 +69,7 @@

{% translate "Find your court case" %}

{{ selected_court_code|json_script:"selected-court-code" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/components/filing_availability_notice.html b/efile_app/efile/templates/efile/components/filing_availability_notice.html new file mode 100644 index 0000000..52e30e0 --- /dev/null +++ b/efile_app/efile/templates/efile/components/filing_availability_notice.html @@ -0,0 +1,6 @@ +
{{ availability_message }}
diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index 0334636..b168635 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -106,6 +106,7 @@

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

{% endif %} {% csrf_token %} + {% include "efile/components/filing_availability_notice.html" %} {% comment %} First, because the rest depends on it: the court lists different @@ -519,6 +520,7 @@

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

{{ extraction_context|json_script:"extraction-context" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/templates/efile/filing_unavailable.html b/efile_app/efile/templates/efile/filing_unavailable.html new file mode 100644 index 0000000..82ef67a --- /dev/null +++ b/efile_app/efile/templates/efile/filing_unavailable.html @@ -0,0 +1,31 @@ +{% extends "efile/workflow_base.html" %} +{% load i18n %} +{% block title %} + {% translate "Filing is unavailable" %} +{% endblock title %} +{% block workflow_content %} +
+

{% translate "Filing is unavailable" %}

+ +
+
+
{% translate "Court" %}
+
+ {{ draft.court_name|default:draft.court_code }} +
+
+
+
{% translate "Case type" %}
+
+ {{ draft.case_type_name|default:draft.case_type_code }} +
+
+
+

{% translate "Your draft is saved. No filing has been sent to the court." %}

+

{% translate "If these details are incorrect, you can correct them below." %}

+ +
+{% endblock workflow_content %} diff --git a/efile_app/efile/templates/efile/organize_documents.html b/efile_app/efile/templates/efile/organize_documents.html index b59a855..ced9c26 100644 --- a/efile_app/efile/templates/efile/organize_documents.html +++ b/efile_app/efile/templates/efile/organize_documents.html @@ -21,6 +21,7 @@

{% translate "Organize your documents" %}

{% csrf_token %} + {% include "efile/components/filing_availability_notice.html" %}
{% ui_text "organize_documents.main_document_question" %} {% if documents|length == 1 %} @@ -55,6 +56,7 @@

{% translate "Organize your documents" %}

data-document-id="{{ document.id }}" data-role="{{ document.role }}" data-filing-type="{{ document.filing_type_code }}" + data-filing-type-name="{{ document.filing_type_name }}" data-document-type="{{ document.document_type_code }}" data-filing-component="{{ document.filing_component_code }}" data-optional-services="{{ document.requested_optional_services|join:',' }}"> @@ -164,5 +166,6 @@

{% translate "Organize your documents" %}

{{ organize_context|json_script:"organize-context" }} {% endblock workflow_content %} {% block extra_js %} + {% endblock extra_js %} diff --git a/efile_app/efile/tests/test_filing_availability.py b/efile_app/efile/tests/test_filing_availability.py new file mode 100644 index 0000000..80a4545 --- /dev/null +++ b/efile_app/efile/tests/test_filing_availability.py @@ -0,0 +1,367 @@ +from unittest.mock import Mock + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.test import RequestFactory +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY +from efile.services.filing_availability import ( + draft_unavailable_message, + filing_unavailable_message, + outgoing_unavailable_message, +) +from efile.tests.helpers import reviewed_document +from efile.views.session_api import forward_final_filing +from efile.views.submission import _claim_for_submission + + +@pytest.fixture +def submission_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="availability-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + workflow_version=2, + existing_case="new", + court_code="cook:law1", + court_name="Cook County", + case_category_code="civil", + case_category_name="Civil", + case_type_code="Contract", + case_type_name="Contract", + ) + reviewed_document( + draft=draft, + role=FilingDocument.Role.LEAD, + sort_order=0, + name="Petition.pdf", + filing_type_code="petition", + filing_type_name="Petition", + document_type_code="public", + ) + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + session["jurisdiction"] = "illinois" + session["auth_tokens"] = {"TYLER-TOKEN-ILLINOIS": "test-token"} + session.save() + return draft + + +@pytest.fixture +def configure(monkeypatch): + def set_config(availability, court="cook:law1"): + monkeypatch.setattr( + "efile.services.filing_availability.config_loader.load_jurisdiction_config", + lambda jurisdiction: {"court_specific_requirements": {court: {"filing_availability": availability}}} + if jurisdiction == "illinois" + else {}, + ) + + return set_config + + +def test_default_enabled_and_jurisdiction_and_court_scope(configure): + configure({"enabled": False}) + assert filing_unavailable_message("illinois", "cook:law1") + assert not filing_unavailable_message("illinois", "cook:law2") + assert not filing_unavailable_message("vermont", "cook:law1") + + +@pytest.mark.parametrize( + "selector,arguments", + [ + ("case_categories", {"case_category": "Human name"}), + ("case_types", {"case_type": "Human name"}), + ("filing_types", {"filing_types": ["unrestricted", "Human name"]}), + ], +) +def test_each_selector_matches_exact_names(configure, selector, arguments): + configure({"rules": [{selector: ["Human name"], "message": "Scheduling is unavailable."}]}) + assert filing_unavailable_message("illinois", "cook:law1", **arguments) == "Scheduling is unavailable." + assert not filing_unavailable_message("illinois", "cook:law1") + + +def test_rules_combine_selectors_and_fall_back_to_court_message(configure): + configure({"message": "Court notice", "rules": [{"case_types": ["Contract"], "filing_types": ["Motion"]}]}) + assert not filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + assert ( + filing_unavailable_message("illinois", "cook:law1", case_type="Contract", filing_types=["Motion"]) + == "Court notice" + ) + + +def test_county_prefix_is_bounded_and_specific_message_wins(monkeypatch): + monkeypatch.setattr( + "efile.services.filing_availability.config_loader.load_jurisdiction_config", + lambda jurisdiction: { + "court_specific_requirements": { + "cook:*": {"filing_availability": {"enabled": False, "message": "County notice"}}, + "cook:law1": { + "filing_availability": { + "enabled": True, + "rules": [{"case_types": ["Contract"], "message": "Hearing scheduling is unavailable."}], + } + }, + } + }, + ) + assert ( + filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + == "Hearing scheduling is unavailable." + ) + assert filing_unavailable_message("illinois", "cook:law1", case_type="other") == "County notice" + assert filing_unavailable_message("illinois", "cook:cd1") == "County notice" + assert not filing_unavailable_message("illinois", "cooksville:law1") + + +def test_empty_rules_do_not_disable_and_specific_overrides_generic(configure): + configure({"rules": [{"message": "No selector"}, {"case_types": []}]}) + assert not filing_unavailable_message("illinois", "cook:law1") + configure({"enabled": False, "message": "Generic", "rules": [{"case_types": ["Contract"], "message": "Specific"}]}) + assert filing_unavailable_message("illinois", "cook:law1", case_type="Contract") == "Specific" + + +@pytest.mark.django_db +def test_supporting_document_blocks_envelope_and_removal_restores_filing(configure, submission_draft): + configure({"rules": [{"filing_types": ["Motion"]}]}) + assert not draft_unavailable_message(submission_draft) + document = FilingDocument.objects.create( + draft=submission_draft, role="supporting", sort_order=1, filing_type_code="123", filing_type_name="Motion" + ) + assert draft_unavailable_message(submission_draft) + document.delete() + assert not draft_unavailable_message(submission_draft) + + +@pytest.mark.django_db +@pytest.mark.parametrize("view", ["document_checklist", "case_review"]) +def test_blocked_page_preserves_draft_and_escapes_message(configure, client, submission_draft, view): + configure({"rules": [{"case_types": ["Contract"], "message": "Scheduling "}]}) + response = client.get(reverse(view, kwargs={"jurisdiction": "illinois"})) + assert response.status_code == 403 + assert b"Scheduling <script>" in response.content + assert b"Correct case details" in response.content + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT + assert submission_draft.documents.exists() + + +@pytest.mark.django_db +def test_direct_submit_cannot_bypass_new_restriction(configure, client, submission_draft, monkeypatch): + configure({"rules": [{"case_types": ["Contract"], "message": "Hearing scheduling is unavailable."}]}) + forward = Mock() + monkeypatch.setattr("efile.views.submission.forward_final_filing", forward) + response = client.post(reverse("submit_final_filing"), {}, content_type="application/json") + assert response.status_code == 403 + assert response.json()["error_code"] == "submission_filing_unavailable" + forward.assert_not_called() + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT + + +@pytest.mark.django_db +def test_outgoing_ids_are_resolved_to_names_before_checking(configure, client, submission_draft, monkeypatch): + configure({"rules": [{"case_types": ["Contract"], "filing_types": ["Blocked filing"]}]}) + request = RequestFactory().post("/") + request.user = submission_draft.user + request.session = client.session + external = Mock() + monkeypatch.setattr("requests.post", external) + monkeypatch.setattr( + "efile.services.filing_availability._EfspLookups.get", + lambda self, url: ( + [{"code": "Contract", "name": "Contract"}] + if "case_types" in url + else [{"code": "allowed", "name": "Allowed filing"}, {"code": "blocked", "name": "Blocked filing"}] + ), + ) + response = forward_final_filing( + request, + { + "efile_data": { + "al_court_bundle": [ + {"filing_type": "allowed"}, + {"filing_type": "blocked", "filing_description": "Allowed filing"}, + ] + } + }, + ) + assert response.status_code == 403 + external.assert_not_called() + + +@pytest.mark.django_db +def test_confirm_new_case_keeps_choices_editable_when_blocked(configure, client, submission_draft): + configure({"rules": [{"case_types": ["Blocked type"], "message": "Scheduling is unavailable."}]}) + response = client.post( + reverse("extraction_review", kwargs={"jurisdiction": "illinois"}), + { + "existing_case": "new", + "court_code": "cook:law1", + "court_name": "Cook County", + "case_category_code": "civil", + "case_type_code": "blocked", + "case_type_name": "Blocked type", + }, + ) + assert response.status_code == 200 + assert b"Scheduling is unavailable." in response.content + assert response.context["extraction_context"]["case_type_code"] == "blocked" + submission_draft.refresh_from_db() + assert submission_draft.case_type_code == "Contract" + + +@pytest.mark.django_db +def test_existing_case_confirmation_is_blocked_but_search_again_works(configure, client, submission_draft): + configure({"enabled": False}) + submission_draft.existing_case = "existing" + submission_draft.previous_case_id = "case-123" + submission_draft.docket_number = "2026-CV-123" + submission_draft.save() + url = reverse("case_confirmation", kwargs={"jurisdiction": "illinois"}) + response = client.get(url) + assert response.context["availability_message"] + assert b'value="yes"' in response.content + assert client.post(url, {"confirmed": "yes"}).status_code == 403 + assert client.post(url, {"confirmed": "no"}).status_code == 302 + + +@pytest.mark.django_db +def test_organize_blocks_supporting_type_but_allows_correction(configure, client, submission_draft): + configure({"rules": [{"filing_types": ["Motion"], "message": "Scheduling is unavailable."}]}) + document = submission_draft.documents.first() + url = reverse("organize_documents", kwargs={"jurisdiction": "illinois"}) + details = {"id": document.pk, "filing_type": "123", "filing_type_name": "Motion", "document_type": "public"} + payload = {"main_document_id": document.pk, "documents": [details]} + response = client.post(url, payload, content_type="application/json") + assert response.status_code == 403 + assert response.json()["error"] == "Scheduling is unavailable." + assert b"Scheduling is unavailable." in client.get(url).content + details["filing_type"] = "petition" + details["filing_type_name"] = "Petition" + assert client.post(url, payload, content_type="application/json").status_code == 200 + + +@pytest.mark.django_db +def test_claim_rechecks_availability(configure, submission_draft): + configure({"enabled": False, "message": "Disabled since review"}) + with pytest.raises(ValueError, match="Disabled since review"): + _claim_for_submission(submission_draft, {}) + submission_draft.refresh_from_db() + assert submission_draft.status == FilingDraft.Status.DRAFT + + +@pytest.mark.django_db +def test_live_api_checks_partial_choices_and_multiple_filing_types(configure, client): + configure({"enabled": False, "message": "Court disabled"}) + url = reverse("api:filing_availability") + params = {"jurisdiction": "illinois", "court": "cook:law1"} + response = client.get(url, params) + assert response.json() == {"success": True, "available": False, "message": "Court disabled"} + assert "no-store" in response.headers["Cache-Control"] + configure({"rules": [{"filing_types": ["Motion"], "message": "Scheduling unavailable"}]}) + assert client.get(url, params).json()["available"] + params["filing_type_name"] = ["petition", "Motion"] + assert not client.get(url, params).json()["available"] + + +@pytest.mark.django_db +def test_live_api_rejects_unknown_jurisdiction(client): + assert client.get(reverse("api:filing_availability"), {"jurisdiction": "unknown"}).status_code == 400 + + +@pytest.mark.parametrize( + "name,blocked", [("Contract", True), ("contract", False), ("Contract dispute", False), ("183541", False)] +) +def test_exact_name_matching_is_not_substring_or_id_matching(configure, name, blocked): + configure({"rules": [{"case_types": ["Contract"]}]}) + assert bool(filing_unavailable_message("illinois", "cook:law1", case_type=name)) == blocked + + +@pytest.mark.parametrize( + "selector,argument", + [("case_categories", "case_category"), ("case_types", "case_type"), ("filing_types", "filing_types")], +) +@pytest.mark.parametrize( + "name,expected", [("MOTION", True), ("Motion to dismiss", True), ("Notice of Motion", False), ("", False)] +) +def test_regex_fullmatches_names_with_explicit_flags(configure, selector, argument, name, expected): + configure({"rules": [{selector: [{"regex": "(?i)motion(?: to .+)?"}]}]}) + value = [name] if argument == "filing_types" else name + assert bool(filing_unavailable_message("illinois", "cook:law1", **{argument: value})) == expected + + +@pytest.mark.django_db +@pytest.mark.parametrize("code", ["183541", "999999"]) +def test_saved_draft_rule_survives_numeric_id_changes(configure, submission_draft, code): + configure({"rules": [{"case_types": ["Contract"]}]}) + submission_draft.case_type_code = code + submission_draft.save() + assert draft_unavailable_message(submission_draft) + + +@pytest.mark.parametrize("code", ["183541", "999999"]) +def test_submit_resolves_current_ids_and_ignores_client_names(configure, monkeypatch, code): + configure({"rules": [{"case_types": ["Contract"]}]}) + monkeypatch.setattr( + "efile.services.filing_availability._EfspLookups.get", lambda self, url: [{"code": code, "name": "Contract"}] + ) + assert outgoing_unavailable_message( + "illinois", + "cook:law1", + {}, + { + "efile_case_type": code, + "case_type_name": "Unrestricted type", + "al_court_bundle": [], + }, + ) + + +def test_submit_blocks_when_names_cannot_be_resolved(configure, monkeypatch): + configure({"rules": [{"case_types": ["Contract"]}]}) + monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lambda self, url: None) + with pytest.raises(ValueError, match="could not confirm"): + outgoing_unavailable_message("illinois", "cook:law1", {}, {"efile_case_type": "123"}) + + +def test_literal_punctuation_and_invalid_regex(configure): + configure({"rules": [{"filing_types": ["Motion (Other)"]}]}) + assert filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion (Other)"]) + assert not filing_unavailable_message("illinois", "cook:law1", filing_types=["Motion Other"]) + configure({"rules": [{"case_types": [{"regex": "["}]}]}) + with pytest.raises(ImproperlyConfigured, match="Invalid filing availability regex"): + filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + + +@pytest.mark.parametrize("selector", [[{"regex": "["}], "Contract", [7]]) +def test_malformed_rules_fail_even_with_blank_names(configure, selector): + # Drafts can save blank names; the rule must still fail before the claim. + configure({"rules": [{"case_types": selector}]}) + with pytest.raises(ImproperlyConfigured): + filing_unavailable_message("illinois", "cook:law1") + + +def test_null_selector_matches_nothing(configure): + configure({"rules": [{"case_types": None}]}) + assert not filing_unavailable_message("illinois", "cook:law1", case_type="Contract") + + +def test_empty_selectors_need_no_code_lookup(configure, monkeypatch): + configure({"rules": [{"case_types": []}]}) + lookup = Mock() + monkeypatch.setattr("efile.services.filing_availability._EfspLookups.get", lookup) + assert not outgoing_unavailable_message("illinois", "cook:law1", {}, {}) + lookup.assert_not_called() + + +@pytest.mark.django_db +def test_draft_check_skips_document_query_without_filing_type_rules( + configure, submission_draft, django_assert_num_queries +): + configure({"rules": [{"case_types": ["Contract"]}]}) + with django_assert_num_queries(0): + assert draft_unavailable_message(submission_draft) diff --git a/efile_app/efile/tests/test_security_boundaries.py b/efile_app/efile/tests/test_security_boundaries.py index 32068ea..4523394 100644 --- a/efile_app/efile/tests/test_security_boundaries.py +++ b/efile_app/efile/tests/test_security_boundaries.py @@ -86,7 +86,9 @@ def test_submission_logs_exclude_secrets_and_contents(caplog, status): patch("efile.views.session_api.describe_efsp_error", return_value="Upstream error"), patch("requests.post", return_value=response), ): - result = forward_final_filing(request, {"efile_data": {"al_court_bundle": "private-payload-marker"}}) + result = forward_final_filing( + request, {"efile_data": {"al_court_bundle": {"elements": [{"filing_type": "private-payload-marker"}]}}} + ) assert result.status_code == (200 if status == 201 else status) assert "Filing submission response status=" in caplog.text for marker in ( diff --git a/efile_app/efile/views/case_confirmation.py b/efile_app/efile/views/case_confirmation.py index 50bde8e..f357985 100644 --- a/efile_app/efile/views/case_confirmation.py +++ b/efile_app/efile/views/case_confirmation.py @@ -5,6 +5,7 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot, write_case_data +from efile.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import link_case_to_plan, remember_case_for_plan from efile.workflow import ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context @@ -28,6 +29,8 @@ def case_confirmation(request, jurisdiction): if request.method == "POST": if request.POST.get("confirmed") == "yes": + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) # The filer has just told us which court case this matter is. Keep # it on the plan so their next filing goes into the same case # without searching for it again. @@ -60,6 +63,7 @@ def case_confirmation(request, jurisdiction): "is_logged_in": True, "filing_draft": draft_snapshot(draft), "case": draft, + "availability_message": draft_unavailable_message(draft), } context.update(get_workflow_context(WorkflowStepKey.CASE_CONFIRMATION, jurisdiction, draft)) return render(request, "efile/case_confirmation.html", context) diff --git a/efile_app/efile/views/document_checklist.py b/efile_app/efile/views/document_checklist.py index f2af517..132e9cf 100644 --- a/efile_app/efile/views/document_checklist.py +++ b/efile_app/efile/views/document_checklist.py @@ -9,6 +9,7 @@ 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.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import ( attach_document_to_item, attach_lead_document, @@ -113,6 +114,8 @@ def document_checklist(request, jurisdiction): current_step=WorkflowStepKey.DOCUMENT_CHECKLIST, workflow_version=2, ) + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) 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.") diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 13b8bb1..515bec3 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -12,6 +12,7 @@ from efile.services.drafts import draft_snapshot, write_case_data from efile.services.extracted_parties import review_rows, save_reviewed_parties from efile.services.extraction_fields import display_extracted_fields, document_summary_details +from efile.services.filing_availability import filing_unavailable_message from efile.services.filing_path import change_filing_path, describe_path_change, filing_path_conflict from efile.workflow import ( RETURN_TO_REVIEW, @@ -131,6 +132,7 @@ def extraction_review(request, jurisdiction): guesses = display_extracted_fields(draft.extracted_guesses or {}) needs_acknowledgement = bool(guesses) acknowledgement_error = False + availability_message = "" if request.method == "POST": existing_case = request.POST.get("existing_case", draft.existing_case) @@ -138,10 +140,20 @@ def extraction_review(request, jurisdiction): case_category_code = request.POST.get("case_category_code", "") case_type_code = request.POST.get("case_type_code", "") + availability_message = filing_unavailable_message( + jurisdiction, + court_code, + case_category=request.POST.get("case_category_name", ""), + case_type=request.POST.get("case_type_name", ""), + filing_types=[request.POST.get("filing_type_name", "")], + ) + offered_roles = {role["id"] for role in _offered_filer_roles(request, jurisdiction)} filer_role = request.POST.get("filer_role", "") - if needs_acknowledgement and request.POST.get("reviewed_extraction") != "yes": + if availability_message: + pass # Show the persistent notice alongside the editable choices. + elif needs_acknowledgement and request.POST.get("reviewed_extraction") != "yes": # Shown beside the checkbox rather than as a toast, so it stays put # and is tied to the control that needs attention. acknowledgement_error = True @@ -278,6 +290,7 @@ def classified(level, key): ) context = { "is_logged_in": True, + "availability_message": availability_message, "lead_document": lead, "filing_draft": draft_snapshot(draft), "has_guesses": needs_acknowledgement, diff --git a/efile_app/efile/views/organize_documents.py b/efile_app/efile/views/organize_documents.py index 6eabcc9..e1e7e46 100644 --- a/efile_app/efile/views/organize_documents.py +++ b/efile_app/efile/views/organize_documents.py @@ -14,6 +14,7 @@ from efile.models import FilingDocument from efile.services.current_drafts import ensure_current_draft from efile.services.drafts import draft_snapshot +from efile.services.filing_availability import draft_unavailable_message from efile.utils.config_loader import config_loader from efile.utils.ui_text import get_texts from efile.workflow import RETURN_TO_REVIEW, ExistingCase, WorkflowStepKey, get_step_url, get_workflow_context @@ -165,6 +166,9 @@ def organize_documents(request, jurisdiction): except (json.JSONDecodeError, ValueError) as error: return JsonResponse({"success": False, "error": str(error)}, status=400) + if message := draft_unavailable_message(draft): + return JsonResponse({"success": False, "error": message}, status=403) + 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 @@ -180,6 +184,7 @@ def organize_documents(request, jurisdiction): confidentiality_config = jurisdiction_config.get("document_confidentiality") or {} context = { "is_logged_in": True, + "availability_message": draft_unavailable_message(draft), "filing_draft": draft_snapshot(draft), "documents": documents, "return_to": request.GET.get("return_to", ""), @@ -188,6 +193,8 @@ def organize_documents(request, jurisdiction): "court": draft.court_code, "case_category": draft.case_category_code, "case_type": draft.case_type_code, + "case_category_name": draft.case_category_name, + "case_type_name": draft.case_type_name, "existing_case": "yes" if draft.existing_case == ExistingCase.EXISTING else "no", "guessed_filing_type": (draft.extracted_guesses or {}).get("filing type", ""), "default_confidentiality": confidentiality_config.get("default", ""), diff --git a/efile_app/efile/views/review.py b/efile_app/efile/views/review.py index c2ad5a7..6095836 100644 --- a/efile_app/efile/views/review.py +++ b/efile_app/efile/views/review.py @@ -9,6 +9,7 @@ from efile.services.drafts import draft_snapshot, read_case_data, read_upload_data from efile.services.extracted_parties import party_display_name from efile.services.fee_quotes import fee_inputs_token, fee_quote_summary +from efile.services.filing_availability import draft_unavailable_message, unavailable_response from efile.services.filing_plans import documents_missing_from_envelope from efile.services.people import get_case_questions @@ -46,6 +47,8 @@ def case_review(request, jurisdiction): current_step=WorkflowStepKey.REVIEW, workflow_version=2, ) + if message := draft_unavailable_message(draft): + return unavailable_response(request, draft, message) if unreviewed_documents(draft).exists(): return redirect(get_step_url(WorkflowStepKey.PREVIEW_DOCUMENTS, jurisdiction) + "?return_to=review") diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 4034c89..df4c7eb 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -9,6 +9,7 @@ from ..services.efsp_errors import describe_efsp_error from ..services.efsp_payload import PayloadValidationError, prepare_efile_payload from ..services.extraction_fields import EXTRACTION_FIELDS, EXTRACTION_HINTS +from ..services.filing_availability import outgoing_unavailable_message from ..services.submission_errors import SubmissionErrorCode from ..utils.case_data_utils import get_case_data, get_upload_data, update_case_data from ..utils.proxy_connection import get_party_type_code_from_api @@ -163,6 +164,17 @@ def forward_final_filing(request, data): status=400, ) + # Resolve the outgoing IDs to authoritative names before applying rules. + try: + message, status = outgoing_unavailable_message(jurisdiction_id, court_id, case_data, efile_data), 403 + except ValueError as error: + message, status = str(error), 412 + if message: + return JsonResponse( + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, + status=status, + ) + # Same fixups the fee quote applied, so the filing matches the quote. try: prepare_efile_payload(efile_data, jurisdiction_id, court_id) diff --git a/efile_app/efile/views/submission.py b/efile_app/efile/views/submission.py index 193b7e0..3222112 100644 --- a/efile_app/efile/views/submission.py +++ b/efile_app/efile/views/submission.py @@ -12,6 +12,7 @@ from efile.services.disclaimers import validate_acceptance from efile.services.document_previews import require_document_previews from efile.services.fee_quotes import fee_quote_is_usable +from efile.services.filing_availability import draft_unavailable_message from efile.services.filing_plans import mark_attached_items_filed from efile.services.submission_errors import PRE_SUBMIT_ERROR_CODES, SubmissionErrorCode @@ -38,6 +39,8 @@ def _claim_for_submission(draft: FilingDraft, acceptance: dict) -> bool: locked = FilingDraft.objects.select_for_update().get(pk=draft.pk) if locked.status not in _CLAIMABLE_STATUSES: return False + if message := draft_unavailable_message(locked): + raise ValueError(message) require_document_previews(locked) claimed = FilingDraft.objects.filter(pk=draft.pk, status__in=_CLAIMABLE_STATUSES).update( status=FilingDraft.Status.SUBMITTING, @@ -103,6 +106,11 @@ def submit_final_filing(request): status=400, ) + if message := draft_unavailable_message(draft): + return JsonResponse( + {"success": False, "error_code": SubmissionErrorCode.FILING_UNAVAILABLE, "error": message}, status=403 + ) + try: require_document_previews(draft) except ValueError as error: diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 6911b5a..dab1bd7 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -652,4 +652,139 @@ test('small screens: editing fits without sideways scrolling', async ({ await field(page, 'case_type').edit.click(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(0); +}); +// Availability is checked as a choice changes, before the form is submitted. +test.describe('live filing availability', () => { + const warning = 'Hearing scheduling is not supported for this selection.'; + // Restrict any selection that sends `blockedName` as `parameter`. + const mockAvailability = (page, parameter, blockedName) => page.route('**/api/filing-availability/**', (route) => { + const unavailable = new URL(route.request().url()).searchParams.getAll(parameter).includes(blockedName); + return route.fulfill({ + json: { + success: true, + available: !unavailable, + message: unavailable ? warning : '' + } + }); + }); + const choices = [ + ['court', 'court', 'vt:washington', 'cook:cvd1', 'vt:washington'], + ['case_category', 'case_category_name', '7000', '6198', 'Civil'], + ['case_type', 'case_type_name', '183542', '183541', 'Tort'], + ['filing_type', 'filing_type_name', '143133', '143132', 'Answer'], + ]; + for (const [name, parameter, blocked, allowed, blockedName] of choices) { + test(`warns immediately for ${name} and clears after correction`, async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockAvailability(page, parameter, blockedName); + await openSavedDraft(page); + const notice = page.locator('#filing-availability-notice'); + const next = page.getByRole('button', { + name: 'Confirm and continue' + }); + await expect(notice).toBeHidden(); + await field(page, name).edit.click(); + await field(page, name).select.selectOption(blocked); + await expect(notice).toHaveText(warning); + await expect(next).toBeDisabled(); + await field(page, name).select.selectOption(allowed); + await expect(notice).toBeHidden(); + await expect(next).toBeEnabled(); + }); + } + + test('a changed numeric ID still sends the human-readable name', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await page.route('**/api/dropdowns/case-types/**', (route) => route.fulfill({ + json: { + success: true, + data: [{ + value: '987654', + text: 'Contract' + }], + } + })); + await mockAvailability(page, 'case_type_name', 'Contract'); + await page.goto(PAGE); + await expect(field(page, 'case_type').select).toBeEnabled(); + await field(page, 'case_type').select.selectOption('987654'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.getByRole('button', { + name: 'Confirm and continue' + })).toBeDisabled(); + }); + + test('a late allowed response cannot clear the current restriction', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + let delayAllowed = false; + let lateDelivered = false; + await page.route('**/api/filing-availability/**', async (route) => { + const blocked = new URL(route.request().url()).searchParams.get('filing_type_name') === 'Answer'; + const delayed = delayAllowed && !blocked; + if (delayed) await new Promise((resolve) => setTimeout(resolve, 500)); + await route.fulfill({ + json: { + success: true, + available: !blocked, + message: blocked ? warning : '' + } + }).catch(() => {}); + if (delayed) lateDelivered = true; + }); + await openSavedDraft(page); + await expect(page.locator('#filing-availability-notice')).toBeHidden(); + await field(page, 'filing_type').edit.click(); + // Leave the saved choice first: an unchanged selection is not re-checked. + await field(page, 'filing_type').select.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + delayAllowed = true; + const requested = page.waitForRequest('**/api/filing-availability/**'); + await field(page, 'filing_type').select.selectOption('143132'); + await requested; + await field(page, 'filing_type').select.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect.poll(() => lateDelivered).toBe(true); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.getByRole('button', { + name: 'Confirm and continue' + })).toBeDisabled(); + }); + + test('organizing warns on a filing-type change before saving', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + for (const path of ['dropdowns/document-types', 'get-filing-components', 'dropdowns/optional-services']) { + await page.route(`**/api/${path}/**`, (route) => route.fulfill({ + json: { + success: true, + data: [] + } + })); + } + await mockAvailability(page, 'filing_type_name', 'Answer'); + await page.goto('/jurisdiction/illinois/organize-documents/'); + const choice = page.locator('.filing-type').first(); + await expect(choice).toBeEnabled(); + await choice.selectOption('143133'); + await expect(page.locator('#filing-availability-notice')).toHaveText(warning); + await expect(page.locator('#save-document-details')).toBeDisabled(); + await choice.selectOption('143132'); + await expect(page.locator('#filing-availability-notice')).toBeHidden(); + await expect(page.locator('#save-document-details')).toBeEnabled(); + }); }); \ No newline at end of file