diff --git a/README_litefile.md b/README_litefile.md new file mode 100644 index 0000000..3ad98bb --- /dev/null +++ b/README_litefile.md @@ -0,0 +1,177 @@ +# Filing through LITEFile + +Include `litefile.yml` after AssemblyLine and display `${ litefile_continue_button }` +on the interview's download screen. All interview-specific customization lives +in that YAML file. The Python module is a reusable transport helper; a future +LITEFile integration package can supply it without changing the data contract. +Until that package exists, copy `litefile.py` unchanged alongside the YAML. + +## Adapt the YAML to another interview + +The two data blocks show what leaves the interview: + +- `litefile_data` uses Docassemble's `data from code` syntax for case facts, + the filer, parties, representation facts, and semantic classification hints. + Change the jurisdiction, intent, variable paths, and party roles here. + `litefile_person(source)` uses the normal AssemblyLine `ALIndividual` and + Docassemble `Individual` name, address, email, and phone attributes. An + adaptation only supplies `fields={...}` when its object differs. Tests or + other callers can override the reader with `known=...`. + `showifdef()` reads only existing answers, so missing facts do not trigger + filing questions in the legal interview. Its alternative value preserves + `False` and zero while allowing unknown facts to be omitted. +- `litefile_document_map` maps ALDocument variable names to stable document + IDs, lead/supporting roles, form names, filing-type hints, document-type hints, + and filing-component hints. Only enabled documents in `al_court_bundle` are + sent. The RFA next-steps document is outside that court bundle. + +For example, the service form's customization is ordinary YAML: + +```yaml +RFAserviceinfo: + role: supporting + form_name: Protection Order Service Info + filing_type_name_hints: + - 'Protection Order Service Information DPS #132' + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document +``` + +Hints are readable names, not Tyler codes. Empty lists leave the choice to the +filer. LITEFile resolves live metadata and remains responsible for optional +services, fees, payment, validation, and submission. A `supporting` PDF's filing +component can still be `Lead Document`: those describe different concepts. + +When names vary by filing location, populate `litefile_filing_hint_overrides`. +County hints replace general hints, then a matching court replaces the county's +values field by field. Document entries use the same stable IDs as +`litefile_document_map`: + +```yaml +variable name: litefile_filing_hint_overrides +data: + counties: + Cook: + case_type_name_hints: + - County-specific case type + documents: + complaint: + filing_type_name_hints: + - County-specific complaint + courts: + First Municipal District: + documents: + complaint: + filing_component_name_hints: + - Court-specific lead document +``` + +Keys are compared as normalized names. A county key may include or omit the +word `County`. General hints remain in effect for fields a matching override +does not mention. + +The adult/minor RFA case-type condition is visible in `litefile_data`. +No Python code chooses Vermont courts, RFA forms, party roles, or case types. +The reusable `litefile_send` and `litefile_upload` events use only generic +`litefile_*` variables. Authors normally edit the data blocks +under “Interview customization” and leave those events unchanged. The default +`litefile_bundle` is `al_court_bundle` and the default download event is +`al_download`; the RFA adaptation overrides the latter in a later block. + +To customize defaults, add blocks **after** the include. Docassemble's later +blocks take precedence, including for data blocks and events: + +```yaml +include: + - litefile.yml +--- +code: | + litefile_bundle = my_court_bundle +--- +code: | + litefile_download_event = "my_interview_download" +``` + +Replace `litefile_data` and `litefile_document_map` with later data blocks for +your interview. You may similarly override `litefile_send` for a custom flow, +but ordinary adaptations do not need to. No extra module import is needed. + +## Configure the servers + +In Docassemble's server configuration: + +```yaml +litefile: + enabled: true + base_url: https://litefile.example.org + source: court-interviews + token: +``` + +This is one configuration per Docassemble server, shared by its interviews. +Production and staging set their own endpoint and credentials. There is no +per-interview nesting. The source name identifies the sending application; +filing intent and jurisdiction belong in the interview data. + +If an interview needs a different top-level server configuration, add: + +```yaml +code: | + litefile_config_name = "alternate_filing" +``` + +Its default is `"litefile"`. The background upload reads the selected server +configuration directly, so credentials are not copied into interview variables +or background-action arguments. Existing servers should move the old +`litefile.rfa` contents up one level to `litefile`. + +Configure the matching source in LITEFile's `LITEFILE_HANDOFF_SOURCES`, allowing +the jurisdiction and this interview server's HTTPS return origin. See +[LITEFile's handoff contract](https://litefile-docs.suffolklitlab.org/docs/partners-courts/interview-integration). +The transfer saves an editable draft; it does not file anything with the court. + +## Background transfer and retries + +The default upload uses Docassemble's +[BackgroundAction](https://docassemble.org/docs/objects.html#BackgroundAction) +with its waiting spinner. The foreground event prepares the AssemblyLine cache; +`BackgroundAction`'s initial wait persists that cache before the upload worker +reads it. Background events read the saved interview state directly, keeping +PII, credentials, and correction tokens out of logged background-job arguments. +An initial routing block consumes the result before the host interview's +mandatory blocks run, then displays the ordinary success/retry screen. +Authors do not need to write polling JavaScript or copy background event code. + +Do not call `litefile_send` as a background action itself: it prepares the cache +and coordinates the upload object. Invoke it with `url_action('litefile_send')` +or use the included button template. + + +The helper uses AssemblyLine's `get_cacheable_documents()` and retains its +cache, including its `DAFile` handles, with the payload for each transfer. +There are no separate frozen PDF copies. Retries reuse the same cache, payload, +source identity, and hashes; file paths are resolved again from the handles. +This avoids timestamp changes from regenerating a PDF on each retry. + +For PDF corrections, start from LITEFile's **Return to my interview to correct a +PDF** button. Edit the answers, then choose **Continue in LITEFile**. A new scoped +correction token creates a new AssemblyLine cache and transfers replacement +PDFs into the same editable LITEFile draft. Retries of that transfer reuse its +cache. Other filing choices remain in LITEFile. Cached files follow the normal +Docassemble session file-retention lifecycle. + +## Verify an adaptation + +Use a Python environment with Docassemble installed and run +`python -I -m pytest -q tests/test_litefile.py`. Isolated mode avoids the local +legacy namespace package shadowing the installed Docassemble package. +The tests read the actual YAML +mapping, cover missing facts and adult/minor hints, and verify cache reuse and +correction behavior. Also exercise the installed interview, because document +enabling and attachment generation depend on the interview's own logic. + +For isolated local testing, both configurations can explicitly enable +`allow_insecure_local_development: true`; the receiver also requires Django +debug mode. Production uses HTTPS. Keep source secrets and test session links +out of Git. diff --git a/docassemble/RFApackage/data/questions/RFApackage.yml b/docassemble/RFApackage/data/questions/RFApackage.yml index 238c161..92e0131 100644 --- a/docassemble/RFApackage/data/questions/RFApackage.yml +++ b/docassemble/RFApackage/data/questions/RFApackage.yml @@ -3,6 +3,7 @@ --- include: - docassemble.AssemblyLine:assembly_line.yml + - litefile.yml - docassemble.ALToolbox:phone-number-validation.yml - docassemble.ALToolbox:display_template.yml - docassemble.VTSharedYMLFile:VTSharedYMLFile.yml @@ -3699,6 +3700,8 @@ subquestion: |
+ ${ litefile_continue_button } +

Documents to download

${ al_user_bundle.download_list_html() } diff --git a/docassemble/RFApackage/data/questions/litefile.yml b/docassemble/RFApackage/data/questions/litefile.yml new file mode 100644 index 0000000..f0edc59 --- /dev/null +++ b/docassemble/RFApackage/data/questions/litefile.yml @@ -0,0 +1,206 @@ +--- +modules: + - .litefile +--- +# Shared defaults and actions. Later interview blocks can override these names. +code: | + litefile_config_name = "litefile" +--- +code: | + litefile_bundle = al_court_bundle +--- +code: | + litefile_download_event = "al_download" +--- +code: | + litefile_transfer_id = litefile_source_id() +--- +code: | + litefile_transfers = {} +--- +variable name: litefile_filing_hint_overrides +data: + counties: {} + courts: {} +--- +code: | + litefile_upload_pending = False +--- +objects: + - litefile_background: BackgroundAction +--- +variable name: litefile_background_failure +data: + ok: false + message: We could not complete the transfer. Try again to continue the same draft. Your forms are still available to download. +--- +event: litefile_send +code: | + litefile_transfer_key = url_args.get('litefile_correction', '') or 'initial' + if litefile_transfer_key not in litefile_transfers: + reconsider("litefile_data") + litefile_prepared = prepare_litefile_transfer( + litefile_data, litefile_bundle, litefile_document_map, + source_id=litefile_transfer_id, + return_url=interview_url() + ) + if not litefile_prepared['ok']: + litefile_result = litefile_prepared + force_ask('litefile_result_screen') + litefile_transfers[litefile_transfer_key] = litefile_prepared + litefile_upload_pending = True + # Start the task while this action is still active. The initial block below + # resumes it after BackgroundAction's wait refresh. + litefile_result = litefile_background.run('litefile_upload') or litefile_background_failure + litefile_upload_pending = False + force_ask('litefile_result_screen') +--- +# An initial block runs before the host interview's mandatory download screen, +# so it survives BackgroundAction's wait refresh and displays the result. +initial: True +code: | + if litefile_upload_pending: + # The first wait response saves the prepared AssemblyLine cache before the + # worker reads it. A retry reuses the same PDFs and hashes. + litefile_result = litefile_background.run('litefile_upload') or litefile_background_failure + litefile_upload_pending = False + force_ask('litefile_result_screen') +--- +event: litefile_upload +code: | + background_response(send_litefile_transfer( + litefile_transfers[litefile_transfer_key], + config=get_config(litefile_config_name, {}), + correction_token=url_args.get('litefile_correction', '') + )) +--- +event: litefile_result_screen +question: | + % if litefile_result['ok']: + Your filing draft is saved + % else: + Your forms are still here + % endif +subquestion: | + % if litefile_result['ok']: + Your forms and answers are saved in LITEFile. Continue there to complete any + missing filing details, review your documents, and submit them to court. + **Your forms have not been filed yet.** + + ${ action_button_html(litefile_result['continue_url'], label='Continue in LITEFile', color='primary') } + % else: + ${ litefile_result['message'] } + + ${ action_button_html(url_action('litefile_send'), label='Try again', color='primary') } + % endif + + ${ action_button_html(url_action(litefile_download_event), label='Return to my forms', color='secondary') } +--- +# Display ${ litefile_continue_button } on the interview download screen. +template: litefile_continue_button +content: | + % if get_config(litefile_config_name, {}).get('enabled', False): + ### Continue with electronic filing + + Send your forms and the answers you already gave to LITEFile. You will review + the filing details there before anything is sent to the court. + + ${ action_button_html(url_action('litefile_send'), label='Continue in LITEFile', color='primary') } + % endif +--- +# Interview customization starts here. The generic actions above stay unchanged. +# This later block overrides the shared return-to-download default. +code: | + litefile_download_event = "RFApackage_download" +--- +# showifdef reads existing answers without asking new interview questions. +# All hints are semantic names. LITEFile resolves current court codes. +variable name: litefile_data +data from code: + schema_version: 1 + jurisdiction: '"vermont"' + filing_intent: '"relief_from_abuse"' + filing_type_name_hints: '["Complaint"]' + case_category_name_hints: '["Family"]' + case_type_name_hints: | + ["Relief from Abuse on Behalf of a Minor"] if showifdef("who_needs_protection") == "order_obo_child" else ["Relief from Abuse"] + # No subtype default: let LITEFile ask if this court needs one. + case_subtype_name_hints: '[]' + filing_hint_overrides: litefile_filing_hint_overrides + case: + existing_case: 'False' + court_name: showifdef("trial_court.name") + county: showifdef("user_selected_county") + docket_number: showifdef("docket_number") + filer: litefile_person("users[0]") + parties: + - source: '"users[0]"' + person: litefile_person("users[0]") + semantic_role: '"plaintiff"' + case_side_hint: '"plaintiff"' + is_self: 'True' + is_filing_party: 'True' + - source: '"other_parties[0]"' + person: litefile_person("other_parties[0]") + semantic_role: '"defendant"' + case_side_hint: '"defendant"' + known_filing_facts: + who_needs_protection: showifdef("who_needs_protection", None) + users[0].is_form_filler: showifdef("users[0].is_form_filler", None) + children.target_number: showifdef("children.target_number", None) +--- +# Keys are ALDocument variable names in al_court_bundle. Only enabled documents +# are sent. Keep these IDs stable so PDF corrections update the same documents. +variable name: litefile_document_map +data: + RFAcomplaint: + role: lead + form_name: RFA Complaint + filing_type_name_hints: + - Complaint + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document + RFAcomplaintonbehalfofminor: + role: lead + form_name: RFA Complaint on behalf of a minor + filing_type_name_hints: + - Complaint + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document + RFAaffidavit: + role: supporting + form_name: RFA Affidavit + filing_type_name_hints: + - Affidavit + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document + RFAaffidavitonbehalfofminor: + role: supporting + form_name: RFA Affidavit on behalf of a minor + filing_type_name_hints: + - Affidavit + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document + RFAconfidentialcontactinfo: + role: supporting + form_name: Address for Notification + filing_type_name_hints: + - Relief from Abuse Litigant Address Form + document_type_name_hints: + - Confidential + filing_component_name_hints: + - Lead Document + RFAserviceinfo: + role: supporting + form_name: Protection Order Service Info + filing_type_name_hints: + - 'Protection Order Service Information DPS #132' + document_type_name_hints: [] + filing_component_name_hints: + - Lead Document +# Empty hint lists deliberately leave a choice to the filer. Optional services, +# fees, and payment choices belong in LITEFile; the interview supplies no defaults. diff --git a/docassemble/RFApackage/litefile.py b/docassemble/RFApackage/litefile.py new file mode 100644 index 0000000..0d5c041 --- /dev/null +++ b/docassemble/RFApackage/litefile.py @@ -0,0 +1,337 @@ +"""Send known interview facts and generated PDFs to LITEFile. + +This module uses Docassemble's showifdef reader and no EFSP integration classes. +The interview declares its facts, person fields, and document hints in YAML. +""" + +import hashlib +import json +import logging +import uuid +from contextlib import ExitStack +from pathlib import Path +from urllib.parse import urlsplit + +import requests +from docassemble.base.functions import showifdef +from typing import Any + + +logger = logging.getLogger(__name__) + + +DEFAULT_LITEFILE_PERSON_FIELDS = { + "first_name": "name.first", + "middle_name": "name.middle", + "last_name": "name.last", + "suffix": "name.suffix", + "address_line_1": "address.address", + "address_line_2": "address.unit", + "city": "address.city", + "state": "address.state", + "zip_code": "address.zip", + "country": "address.country", + "email": "email", + "phone": "phone_number", +} + + +def litefile_source_id(): + """Generate a unique identifier for a LITEFile transfer. + + Returns: + str: A UUID string suitable for use as a transfer source and + idempotency identifier. + """ + return str(uuid.uuid4()) + + +def litefile_person( + source: str, *, fields: dict[str, str] | None = None, known: Any = None +): + """Read available fields for a person from the interview state. + + Empty and missing values are omitted from the returned mapping. The + default field mapping matches the standard AssemblyLine ``ALIndividual`` + fields, but callers can provide a smaller or customized mapping. + + Args: + source: Expression identifying the person, such as ``"users[0]"``. + fields: Mapping from LITEFile field names to suffixes passed to the + reader. If omitted, the standard AssemblyLine person fields are + used. + known: Optional replacement for ``showifdef``. This is useful for + tests or callers that provide their own interview-state reader. + + Returns: + dict[str, str]: The declared fields whose values are neither ``None`` + nor an empty string. + """ + if known is None: + known = showifdef + if fields is None: + fields = DEFAULT_LITEFILE_PERSON_FIELDS + return { + field: str(answer) + for field, suffix in fields.items() + if (answer := known(f"{source}.{suffix}")) not in (None, "") + } + + +def build_litefile_payload( + data: dict, source_id: str, documents: list, return_url: str +) -> dict: + """Build the serialized payload for a LITEFile handoff. + + Person data nested under ``person`` is flattened into each party, null + known filing facts are removed, and each document receives a SHA-256 hash + calculated from its PDF bytes. The original input mapping is not mutated. + + Args: + data: Facts and filing hints declared by the interview YAML. + source_id: Stable identifier for this transfer and its retries. + documents: Document metadata mappings. Each mapping must contain a + ``file`` object with a ``path()`` method returning the PDF path. + return_url: URL to which LITEFile should return the user. + + Returns: + dict: A LITEFile payload containing transfer metadata, document + manifests, and the supplied interview data. + """ + from copy import deepcopy + + payload = deepcopy(data) + # The YAML groups each person's selected attributes for readability. + payload["parties"] = [ + { + **{key: value for key, value in party.items() if key != "person"}, + **party["person"], + } + for party in payload.get("parties", []) + if party.get("person") + ] + payload["known_filing_facts"] = { + key: value + for key, value in payload.get("known_filing_facts", {}).items() + if value is not None + } + manifest = [] + for document in documents: + with open(document["file"].path(), "rb") as pdf: + digest = hashlib.sha256() + for chunk in iter(lambda: pdf.read(65536), b""): + digest.update(chunk) + manifest.append( + { + **{ + key: value + for key, value in document.items() + if key not in ("file", "path") + }, + "sha256": digest.hexdigest(), + } + ) + payload.update( + source_id=source_id, + idempotency_key=source_id, + documents=manifest, + return_url=return_url, + ) + return payload + + +def prepare_litefile_transfer( + data: dict, bundle, document_map, *, source_id: str, return_url: str +) -> dict: + """Prepare an AssemblyLine cache for the foreground interview to persist. + + This does not contact LITEFile. Save the returned cache before uploading it, + so a failed upload can reuse exactly the same payload and PDF bytes. + + Args: + data: Facts and filing hints declared by the interview YAML. + bundle: AssemblyLine document bundle used to find enabled documents and + generate their final PDFs. + document_map: Mapping from document instance names to LITEFile + document metadata. + source_id: Stable identifier for this transfer and its retries. + return_url: URL to which LITEFile should return the user. + + Returns: + dict: A successful result containing ``cache``, ``documents``, and + ``payload``; or an unsuccessful result with ``ok`` set to ``False`` + and a user-facing ``message`` when document preparation cannot proceed. + """ + failure = { + "ok": False, + "message": "We could not prepare these forms. You can still download them.", + } + try: + enabled = bundle.enabled_documents() + ids = [str(document.instanceName) for document in enabled] + if len(ids) != len(set(ids)): + return failure + if any(document_id not in document_map for document_id in ids): + return { + "ok": False, + "message": "A court document is missing its LITEFile mapping. You can still download your forms.", + } + cache = bundle.get_cacheable_documents( + key="final", + pdf=True, + docx=False, + refresh=True, + include_zip=False, + include_full_pdf=False, + ) + cached_documents = cache[0] + if len(cached_documents) != len(ids): + return { + "ok": False, + "message": "The court document list changed. Please try again.", + } + if any( + not isinstance(item, dict) or "pdf" not in item for item in cached_documents + ): + return failure + cache_ids = [ + item.get("id", item.get("instanceName")) for item in cached_documents + ] + if any(cache_id is not None for cache_id in cache_ids): + if [ + str(cache_id) if cache_id is not None else None + for cache_id in cache_ids + ] != ids: + return failure + documents = [ + {**document_map[document_id], "id": document_id, "file": item["pdf"]} + for document_id, item in zip(ids, cached_documents) + ] + return { + "ok": True, + "cache": cache, + "documents": documents, + "payload": build_litefile_payload(data, source_id, documents, return_url), + } + except (OSError, KeyError, TypeError, ValueError): + logger.exception("Could not prepare the LITEFile document transfer") + return failure + + +def send_litefile_transfer(transfer: dict, *, config: dict, correction_token: str = ""): + """Upload a previously saved transfer without regenerating its PDFs. + + Args: + transfer: Result returned by ``prepare_litefile_transfer``. + config: LITEFile connection configuration containing ``base_url``, + ``source``, and ``token``. + correction_token: Optional token for replacing documents in an + existing handoff. + + Returns: + dict: The successful handoff receipt or a recoverable failure result. + """ + documents = [ + {**document, "path": document["file"].path()} + for document in transfer["documents"] + ] + return send_litefile_handoff( + transfer["payload"], documents, config, correction_token + ) + + +def send_litefile_handoff(payload, documents, config, correction_token=""): + """Send a payload and its PDFs to LITEFile. + + Retries preserve the payload's idempotency key. A correction upload uses a + distinct deterministic key derived from the payload and correction token. + Invalid configuration, failed requests, malformed receipts, and + unexpected continuation origins are returned as recoverable failures. + + Args: + payload: Serialized LITEFile handoff payload. + documents: Document mappings containing ``id`` and local PDF ``path`` + values. + config: LITEFile connection configuration. HTTPS is required unless + ``allow_insecure_local_development`` is explicitly true for an + HTTP local-development URL. + correction_token: Optional token for replacing documents in an + existing handoff. + + Returns: + dict: A result with ``ok`` set to ``True`` and handoff receipt fields + on success, or ``ok`` set to ``False`` and a user-facing ``message`` + on failure. + """ + base_url = str(config.get("base_url", "")).rstrip("/") + source = config.get("source", "") + token = config.get("token", "") + parsed = urlsplit(base_url) + # HTTP is an explicit local-development option, never the production default. + if ( + not source + or not token + or not parsed.netloc + or ( + parsed.scheme != "https" + and not ( + parsed.scheme == "http" + and config.get("allow_insecure_local_development") is True + ) + ) + ): + return { + "ok": False, + "message": "Electronic filing is not configured. You can still download your forms.", + } + outgoing = dict(payload) + headers = {"Authorization": f"Bearer {token}", "X-LITEFile-Source": source} + endpoint = "/api/handoffs/v1/" + if correction_token: + endpoint += "documents/" + headers["X-LITEFile-Correction"] = correction_token + material = json.dumps(payload, sort_keys=True) + correction_token + outgoing["idempotency_key"] = hashlib.sha256(material.encode()).hexdigest() + try: + with ExitStack() as stack: + files = { + doc["id"]: ( + Path(doc["path"]).name, + stack.enter_context(open(doc["path"], "rb")), + "application/pdf", + ) + for doc in documents + } + response = requests.post( + base_url + endpoint, + headers=headers, + data={"payload": json.dumps(outgoing)}, + files=files, + timeout=(10, 120), + allow_redirects=False, + ) + if response.status_code not in (200, 201): + return { + "ok": False, + "message": "LITEFile could not receive these forms. Try again, or download them below. If you already sent this draft and changed a PDF, open LITEFile and use the return-to-interview link.", + } + receipt = response.json() + if not isinstance(receipt, dict): + raise ValueError("Malformed handoff receipt") + continue_url = receipt.get("continue_url", "") + if not isinstance(continue_url, str): + raise ValueError("Malformed continuation URL") + continuation = urlsplit(continue_url) + if (continuation.scheme, continuation.netloc) != (parsed.scheme, parsed.netloc): + raise ValueError("Unexpected continuation origin") + if not receipt.get("draft_id") or not continuation.path.startswith("/handoff/"): + raise ValueError("Incomplete handoff receipt") + return { + "ok": True, + **{key: receipt[key] for key in ("draft_id", "state", "continue_url")}, + } + except (requests.RequestException, OSError, ValueError, KeyError): + return { + "ok": False, + "message": "We could not confirm the transfer. Try again to continue the same draft. Your forms are still available to download.", + } diff --git a/tests/test_litefile.py b/tests/test_litefile.py new file mode 100644 index 0000000..a6d2094 --- /dev/null +++ b/tests/test_litefile.py @@ -0,0 +1,439 @@ +"""The adapter can be tested without starting Docassemble or an EFSP client.""" + +import hashlib +import importlib.util +from functools import partial +from pathlib import Path +from unittest.mock import Mock, patch + +import yaml +import pytest + +MODULE = Path(__file__).parents[1] / "docassemble" / "RFApackage" / "litefile.py" +spec = importlib.util.spec_from_file_location("rfa_litefile", MODULE) +adapter = importlib.util.module_from_spec(spec) +spec.loader.exec_module(adapter) + + +YAML = MODULE.parent / "data/questions/litefile.yml" +BLOCKS = { + block.get("variable name"): block + for block in yaml.safe_load_all(YAML.read_text()) + if block +} + + +def declared_data(answers=None): + answers = answers or {} + + def reader(path, alternative=""): + return answers.get(path, alternative) + + namespace = { + "showifdef": reader, + "litefile_person": partial(adapter.litefile_person, known=reader), + "litefile_filing_hint_overrides": BLOCKS["litefile_filing_hint_overrides"][ + "data" + ], + } + + def evaluate(node): + if isinstance(node, dict): + return {key: evaluate(value) for key, value in node.items()} + if isinstance(node, list): + return [evaluate(value) for value in node] + return eval(str(node), namespace) + + return evaluate(BLOCKS["litefile_data"]["data from code"]) + + +def bundle(tmp_path): + pdf = tmp_path / "complaint.pdf" + pdf.write_bytes(b"%PDF-1.4\nsynthetic test") + file = Mock() + file.path.return_value = str(pdf) + return [ + { + **BLOCKS["litefile_document_map"]["data"]["RFAcomplaint"], + "id": "RFAcomplaint", + "path": str(pdf), + "file": file, + } + ] + + +def config(): + return { + "source": "rfa", + "token": "secret", + "base_url": "https://litefile.example.org", + } + + +def test_missing_answers_never_trigger_interview_resolution(tmp_path): + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(declared_data(), "stable", docs, "") + assert payload["filer"] == {} + assert payload["parties"] == [] + assert ( + payload["documents"][0]["sha256"] + == hashlib.sha256(Path(docs[0]["path"]).read_bytes()).hexdigest() + ) + assert not any(key.endswith("_code") for key in payload) + + +def test_minor_uses_distinct_semantic_case_type_and_reuses_known_contact(tmp_path): + answers = { + "who_needs_protection": "order_obo_child", + "users[0].name.first": "Taylor", + "users[0].email": "test@example.org", + "user_selected_county": "Chittenden", + } + payload = adapter.build_litefile_payload( + declared_data(answers), "stable", bundle(tmp_path), "" + ) + assert payload["case_type_name_hints"] == ["Relief from Abuse on Behalf of a Minor"] + assert payload["filer"]["email"] == "test@example.org" + assert payload["case"]["county"] == "Chittenden" + + +def test_retries_keep_key_and_source_secret_stays_in_header(tmp_path): + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(declared_data(), "stable", docs, "") + response = Mock(status_code=201) + response.json.return_value = { + "draft_id": "42", + "state": "needs_input", + "continue_url": "https://litefile.example.org/handoff/claim/token/", + } + with patch.object(adapter.requests, "post", return_value=response) as post: + first = adapter.send_litefile_handoff(payload, docs, config()) + second = adapter.send_litefile_handoff(payload, docs, config()) + assert first == second + assert first["ok"] + assert ( + post.call_args_list[0].kwargs["data"] == post.call_args_list[1].kwargs["data"] + ) + assert post.call_args.kwargs["headers"]["Authorization"] == "Bearer secret" + assert "secret" not in post.call_args.kwargs["data"]["payload"] + assert post.call_args.kwargs["allow_redirects"] is False + + +def test_unexpected_continuation_origin_and_network_errors_are_recoverable(tmp_path): + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(declared_data(), "stable", docs, "") + response = Mock(status_code=201) + response.json.return_value = { + "draft_id": "42", + "state": "needs_input", + "continue_url": "https://untrusted.example/handoff/claim/token/", + } + with patch.object(adapter.requests, "post", return_value=response): + assert not adapter.send_litefile_handoff(payload, docs, config())["ok"] + with patch.object(adapter.requests, "post", side_effect=adapter.requests.Timeout()): + result = adapter.send_litefile_handoff(payload, docs, config()) + assert not result["ok"] + assert "same draft" in result["message"] + + +def test_malformed_json_receipts_are_recoverable(tmp_path): + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(declared_data(), "stable", docs, "") + response = Mock(status_code=201) + response.json.return_value = [] + with patch.object(adapter.requests, "post", return_value=response): + result = adapter.send_litefile_handoff(payload, docs, config()) + assert not result["ok"] + assert "same draft" in result["message"] + + +def test_document_replacement_has_stable_distinct_idempotency_key(tmp_path): + import json + + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(declared_data(), "stable", docs, "") + response = Mock(status_code=200) + response.json.return_value = { + "draft_id": "42", + "state": "needs_input", + "continue_url": "https://litefile.example.org/handoff/drafts/42/", + } + with patch.object(adapter.requests, "post", return_value=response) as post: + adapter.send_litefile_handoff(payload, docs, config(), "correction-token") + adapter.send_litefile_handoff(payload, docs, config(), "correction-token") + requests = post.call_args_list + assert requests[0].args[0].endswith("/api/handoffs/v1/documents/") + assert requests[0].kwargs["data"] == requests[1].kwargs["data"] + sent = json.loads(requests[0].kwargs["data"]["payload"]) + assert sent["idempotency_key"] != payload["idempotency_key"] + assert sent["source_id"] == payload["source_id"] + + +def test_declarative_hints_and_unknown_facts_are_transmitted(tmp_path): + data = declared_data( + { + "users[0].name.first": "Taylor", + "users[0].is_form_filler": False, + "children.target_number": 0, + } + ) + docs = bundle(tmp_path) + payload = adapter.build_litefile_payload(data, "stable", docs, "") + assert payload["filing_type_name_hints"] == ["Complaint"] + assert payload["case_category_name_hints"] == ["Family"] + assert payload["case_type_name_hints"] == ["Relief from Abuse"] + assert payload["documents"][0]["filing_component_name_hints"] == ["Lead Document"] + assert payload["documents"][0]["filing_type_name_hints"] == ["Complaint"] + assert payload["parties"][0]["semantic_role"] == "plaintiff" + assert payload["parties"][0]["first_name"] == "Taylor" + assert payload["known_filing_facts"] == { + "users[0].is_form_filler": False, + "children.target_number": 0, + } + assert "file" not in payload["documents"][0] + assert "path" not in payload["documents"][0] + + +def test_declarative_county_and_court_hint_overrides_are_transmitted(tmp_path): + data = declared_data() + data["filing_hint_overrides"] = { + "counties": { + "Cook": { + "case_type_name_hints": ["County-specific case type"], + "documents": { + "RFAcomplaint": { + "filing_type_name_hints": ["County-specific complaint"] + } + }, + } + }, + "courts": { + "First Municipal District": { + "documents": { + "RFAcomplaint": { + "filing_component_name_hints": ["Court-specific lead"] + } + } + } + }, + } + payload = adapter.build_litefile_payload(data, "stable", bundle(tmp_path), "") + assert payload["filing_hint_overrides"] == data["filing_hint_overrides"] + + +def test_prepared_cache_can_be_uploaded_repeatedly_without_regeneration(tmp_path): + documents = bundle(tmp_path) + al_bundle = Mock() + al_bundle.enabled_documents.return_value = [Mock(instanceName="RFAcomplaint")] + al_bundle.get_cacheable_documents.return_value = ( + [{"pdf": documents[0]["file"]}], + None, + None, + ) + mapping = BLOCKS["litefile_document_map"]["data"] + with patch.object( + adapter, "send_litefile_handoff", return_value={"ok": True} + ) as send: + transfer = adapter.prepare_litefile_transfer( + declared_data(), al_bundle, mapping, source_id="stable", return_url="" + ) + send.assert_not_called() + adapter.send_litefile_transfer(transfer, config=config()) + first = send.call_args.args[0] + adapter.send_litefile_transfer(transfer, config=config()) + assert send.call_args.args[0] == first + assert al_bundle.get_cacheable_documents.call_count == 1 + corrected = adapter.prepare_litefile_transfer( + declared_data({"user_selected_county": "Changed"}), + al_bundle, + mapping, + source_id="stable", + return_url="", + ) + adapter.send_litefile_transfer( + corrected, config=config(), correction_token="correction" + ) + assert al_bundle.get_cacheable_documents.call_count == 2 + assert send.call_args.args[0]["case"]["county"] == "Changed" + assert send.call_args.args[0]["source_id"] == "stable" + al_bundle.get_cacheable_documents.assert_called_with( + key="final", + pdf=True, + docx=False, + refresh=True, + include_zip=False, + include_full_pdf=False, + ) + + +def test_unmapped_enabled_document_does_not_get_guessed_metadata(): + al_bundle = Mock() + al_bundle.enabled_documents.return_value = [Mock(instanceName="not_declared")] + result = adapter.prepare_litefile_transfer( + declared_data(), al_bundle, {}, source_id="stable", return_url="" + ) + assert not result["ok"] + al_bundle.get_cacheable_documents.assert_not_called() + + +def test_mismatched_cache_document_ids_are_recoverable(): + al_bundle = Mock() + al_bundle.enabled_documents.return_value = [ + Mock(instanceName="RFAcomplaint"), + Mock(instanceName="RFAaffidavit"), + ] + al_bundle.get_cacheable_documents.return_value = ( + [ + {"id": "RFAaffidavit", "pdf": Mock()}, + {"id": "RFAcomplaint", "pdf": Mock()}, + ], + None, + None, + ) + mapping = BLOCKS["litefile_document_map"]["data"] + result = adapter.prepare_litefile_transfer( + declared_data(), al_bundle, mapping, source_id="stable", return_url="" + ) + assert not result["ok"] + assert "prepare" in result["message"] + + +def test_invalid_cached_document_is_recoverable(): + al_bundle = Mock() + al_bundle.enabled_documents.return_value = [Mock(instanceName="RFAcomplaint")] + al_bundle.get_cacheable_documents.return_value = ([{}], None, None) + mapping = BLOCKS["litefile_document_map"]["data"] + result = adapter.prepare_litefile_transfer( + declared_data(), al_bundle, mapping, source_id="stable", return_url="" + ) + assert not result["ok"] + assert "prepare" in result["message"] + + +def test_document_resolution_exceptions_reach_docassemble(): + class DocumentResolutionNeeded(IndexError): + pass + + al_bundle = Mock() + al_bundle.enabled_documents.return_value = [Mock(instanceName="RFAcomplaint")] + al_bundle.get_cacheable_documents.side_effect = DocumentResolutionNeeded() + mapping = BLOCKS["litefile_document_map"]["data"] + + with pytest.raises(DocumentResolutionNeeded): + adapter.prepare_litefile_transfer( + declared_data(), al_bundle, mapping, source_id="stable", return_url="" + ) + + +def test_person_defaults_to_showifdef_and_accepts_keyword_override(): + fields = {"first_name": "name.first"} + with patch.object(adapter, "showifdef", return_value="Taylor") as reader: + assert adapter.litefile_person("users[0]", fields=fields) == { + "first_name": "Taylor" + } + assert adapter.litefile_person("users[0]", fields=fields, known=None) == { + "first_name": "Taylor" + } + assert reader.call_count == 2 + reader.assert_called_with("users[0].name.first") + assert adapter.litefile_person("users[0]", fields=fields, known={}.get) == {} + assert reader.call_count == 2 + + +def test_person_default_fields_match_assemblyline_individual(): + answers = { + "users[0].name.first": "Taylor", + "users[0].name.last": "Example", + "users[0].address.address": "100 Main Street", + "users[0].phone_number": "802-555-0123", + } + assert adapter.litefile_person("users[0]", known=answers.get) == { + "first_name": "Taylor", + "last_name": "Example", + "address_line_1": "100 Main Street", + "phone": "802-555-0123", + } + + +def test_background_flow_saves_cache_before_upload_and_reuses_it_on_retry(): + blocks = list(yaml.safe_load_all(YAML.read_text())) + events = { + block["event"]: block["code"] + for block in blocks + if block and "event" in block and "code" in block + } + pending_upload = next( + block["code"] + for block in blocks + if block + and block.get("initial") is True + and "litefile_upload_pending" in block.get("code", "") + ) + + class Waiting(Exception): + pass + + class ResultScreen(Exception): + pass + + transfer = {"ok": True, "payload": {"source_id": "stable"}} + result = {"ok": True, "draft_id": "42"} + background = Mock() + background.run.side_effect = [Waiting(), result, Waiting(), result] + context = { + "litefile_background": background, + "reconsider": Mock(), + "url_args": {}, + "litefile_transfers": {}, + "litefile_data": {}, + "litefile_transfer_id": "stable", + "litefile_bundle": Mock(), + "litefile_document_map": {}, + "litefile_config_name": "litefile", + "litefile_upload_pending": False, + "litefile_background_failure": {"ok": False}, + "interview_url": lambda: "https://interview.example/", + "prepare_litefile_transfer": Mock(return_value=transfer), + "force_ask": Mock(side_effect=ResultScreen), + } + with pytest.raises(Waiting): + exec(events["litefile_send"], context) + assert context["litefile_transfers"]["initial"] is transfer + assert context["litefile_upload_pending"] is True + context["prepare_litefile_transfer"].assert_called_once() + with pytest.raises(ResultScreen): + exec(pending_upload, context) + assert context["litefile_result"] is result + assert context["litefile_upload_pending"] is False + with pytest.raises(Waiting): + exec(events["litefile_send"], context) + with pytest.raises(ResultScreen): + exec(pending_upload, context) + assert [call.args for call in background.run.call_args_list] == [ + ("litefile_upload",), + ("litefile_upload",), + ("litefile_upload",), + ("litefile_upload",), + ] + + +def test_upload_reads_selected_flat_server_configuration(): + event = next( + block["code"] + for block in yaml.safe_load_all(YAML.read_text()) + if block and block.get("event") == "litefile_upload" + ) + for name in ("litefile", "alternate_filing"): + selected = config() + context = { + "litefile_transfer_key": "initial", + "litefile_config_name": name, + "url_args": {}, + "litefile_transfers": {"initial": {"payload": {}}}, + "get_config": Mock(return_value=selected), + "send_litefile_transfer": Mock(return_value={"ok": True}), + "background_response": Mock(), + } + exec(event, context) + context["get_config"].assert_called_once_with(name, {}) + assert context["send_litefile_transfer"].call_args.kwargs["config"] is selected