From 20d0fa673d45b5d44c8aa4b0d6d94a67bda6f345 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:27:58 -0400 Subject: [PATCH 01/18] Add durable filing draft models --- efile_app/efile/models.py | 172 +++++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 3 deletions(-) diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 11a6518..873f171 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -1,6 +1,8 @@ # models.py - Optional extension to store additional user information +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.utils import timezone class UserProfile(AbstractUser): @@ -28,6 +30,170 @@ class Meta: verbose_name_plural = "User Profiles" -# Don't forget to run migrations if you add this model: -# python manage.py makemigrations -# python manage.py migrate --run-syncdb +class FilingDraft(models.Model): + """Durable aggregate for a single in-progress or submitted court filing.""" + + class Status(models.TextChoices): + DRAFT = "draft", "Draft" + SUBMITTING = "submitting", "Submitting" + SUBMITTED = "submitted", "Submitted" + ERROR = "error", "Error" + ABANDONED = "abandoned", "Abandoned" + + class WorkflowStep(models.TextChoices): + OPTIONS = "options", "Options" + UPLOAD_FIRST = "upload_first", "Upload lead document" + CASE_INFORMATION = "case_information", "Case information" + DOCUMENTS = "documents", "Documents" + PAYMENT = "payment", "Payment" + REVIEW = "review", "Review" + CONFIRMATION = "confirmation", "Confirmation" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="filing_drafts", + ) + session_key = models.CharField(max_length=80, blank=True, db_index=True) + session_id = models.CharField(max_length=100, blank=True, db_index=True) + jurisdiction = models.CharField(max_length=40, db_index=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True) + current_step = models.CharField(max_length=64, choices=WorkflowStep.choices, default=WorkflowStep.OPTIONS) + + existing_case = models.CharField(max_length=20, blank=True) + court_code = models.CharField(max_length=100, blank=True) + court_name = models.CharField(max_length=255, blank=True) + case_category_code = models.CharField(max_length=100, blank=True) + case_category_name = models.CharField(max_length=255, blank=True) + case_type_code = models.CharField(max_length=100, blank=True) + case_type_name = models.CharField(max_length=255, blank=True) + case_subtype_code = models.CharField(max_length=100, blank=True) + case_subtype_name = models.CharField(max_length=255, blank=True) + filing_type_code = models.CharField(max_length=100, blank=True) + filing_type_name = models.CharField(max_length=255, blank=True) + document_type_code = models.CharField(max_length=100, blank=True) + document_type_name = models.CharField(max_length=255, blank=True) + + previous_case_id = models.CharField(max_length=255, blank=True) + docket_number = models.CharField(max_length=255, blank=True) + + selected_payment_account_id = models.CharField(max_length=255, blank=True) + selected_payment_account_name = models.CharField(max_length=255, blank=True) + + optional_services = models.JSONField(default=list, blank=True) + extracted_guesses = models.JSONField(default=dict, blank=True) + extra_case_data = models.JSONField(default=dict, blank=True) + submission_response = models.JSONField(default=dict, blank=True) + + submitted_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + indexes = [ + models.Index(fields=["user", "status"]), + models.Index(fields=["jurisdiction", "status"]), + models.Index(fields=["status", "updated_at"]), + ] + + def __str__(self): + return f"{self.get_status_display()} filing draft #{self.pk} ({self.jurisdiction})" + + def mark_submitted(self, response_data): + self.status = self.Status.SUBMITTED + self.current_step = self.WorkflowStep.CONFIRMATION + self.submission_response = response_data or {} + self.submitted_at = timezone.now() + self.save(update_fields=["status", "current_step", "submission_response", "submitted_at", "updated_at"]) + + def mark_error(self, response_data): + self.status = self.Status.ERROR + self.submission_response = response_data or {} + self.save(update_fields=["status", "submission_response", "updated_at"]) + + +class FilingDocument(models.Model): + """Uploaded document that belongs to a filing draft.""" + + class Role(models.TextChoices): + LEAD = "lead", "Lead document" + SUPPORTING = "supporting", "Supporting document" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE, related_name="documents") + role = models.CharField(max_length=20, choices=Role.choices) + sort_order = models.PositiveIntegerField(default=0) + + name = models.CharField(max_length=255, blank=True) + original_filename = models.CharField(max_length=255, blank=True) + size = models.PositiveBigIntegerField(blank=True, null=True) + content_type = models.CharField(max_length=255, blank=True) + s3_key = models.CharField(max_length=1024, blank=True) + public_url = models.URLField(max_length=2048, blank=True) + + filing_type_code = models.CharField(max_length=100, blank=True) + filing_type_name = models.CharField(max_length=255, blank=True) + document_type_code = models.CharField(max_length=100, blank=True) + document_type_name = models.CharField(max_length=255, blank=True) + filing_component_code = models.CharField(max_length=100, blank=True) + filing_component_name = models.CharField(max_length=255, blank=True) + + courtesy_copy_email = models.EmailField(blank=True) + metadata = models.JSONField(default=dict, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["role", "sort_order", "created_at"] + constraints = [ + models.UniqueConstraint(fields=["draft", "role", "sort_order"], name="unique_document_order_per_draft_role"), + ] + + def __str__(self): + return self.name or f"{self.get_role_display()} for draft #{self.draft_id}" + + +class FilingParty(models.Model): + """Person or organization associated with a filing draft.""" + + draft = models.ForeignKey(FilingDraft, on_delete=models.CASCADE, related_name="parties") + role = models.CharField(max_length=50) + sort_order = models.PositiveIntegerField(default=0) + + party_type = models.CharField(max_length=100, blank=True) + external_party_id = models.CharField(max_length=255, blank=True) + + first_name = models.CharField(max_length=100, blank=True) + middle_name = models.CharField(max_length=100, blank=True) + last_name = models.CharField(max_length=100, blank=True) + suffix = models.CharField(max_length=50, blank=True) + organization_name = models.CharField(max_length=255, blank=True) + + email = models.EmailField(blank=True) + phone = models.CharField(max_length=50, blank=True) + + address_line_1 = models.CharField(max_length=255, blank=True) + address_line_2 = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=100, blank=True) + state = models.CharField(max_length=50, blank=True) + zip_code = models.CharField(max_length=20, blank=True) + country = models.CharField(max_length=2, default="US", blank=True) + + metadata = models.JSONField(default=dict, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["role", "sort_order", "created_at"] + constraints = [ + models.UniqueConstraint(fields=["draft", "role", "sort_order"], name="unique_party_order_per_draft_role"), + ] + verbose_name_plural = "Filing parties" + + def __str__(self): + display_name = " ".join(part for part in [self.first_name, self.middle_name, self.last_name] if part) + return display_name or self.organization_name or f"{self.role} for draft #{self.draft_id}" From c28dc88f92a52d5f2a96315626ec2346078f01d7 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:28:14 -0400 Subject: [PATCH 02/18] Register filing draft models in admin --- efile_app/efile/admin.py | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 efile_app/efile/admin.py diff --git a/efile_app/efile/admin.py b/efile_app/efile/admin.py new file mode 100644 index 0000000..9574799 --- /dev/null +++ b/efile_app/efile/admin.py @@ -0,0 +1,87 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from .models import FilingDocument, FilingDraft, FilingParty, UserProfile + + +class FilingDocumentInline(admin.TabularInline): + model = FilingDocument + extra = 0 + fields = ( + "role", + "sort_order", + "name", + "filing_type_code", + "document_type_code", + "filing_component_code", + "public_url", + ) + readonly_fields = ("created_at", "updated_at") + + +class FilingPartyInline(admin.TabularInline): + model = FilingParty + extra = 0 + fields = ("role", "sort_order", "party_type", "first_name", "last_name", "email", "phone") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(UserProfile) +class UserProfileAdmin(UserAdmin): + fieldsets = UserAdmin.fieldsets + ( + ( + "eFile profile", + { + "fields": ( + "tyler_jurisdiction", + "tyler_user_id", + "email_updates", + "text_updates", + ) + }, + ), + ) + list_display = ("username", "email", "tyler_jurisdiction", "tyler_user_id", "is_staff") + + +@admin.register(FilingDraft) +class FilingDraftAdmin(admin.ModelAdmin): + inlines = [FilingDocumentInline, FilingPartyInline] + list_display = ( + "id", + "user", + "jurisdiction", + "status", + "current_step", + "court_code", + "case_type_code", + "updated_at", + ) + list_filter = ("jurisdiction", "status", "current_step", "created_at", "updated_at") + search_fields = ( + "id", + "session_id", + "court_code", + "court_name", + "case_type_code", + "case_type_name", + "docket_number", + "previous_case_id", + "user__username", + "user__email", + ) + readonly_fields = ("created_at", "updated_at", "submitted_at") + + +@admin.register(FilingDocument) +class FilingDocumentAdmin(admin.ModelAdmin): + list_display = ("id", "draft", "role", "sort_order", "name", "document_type_code", "updated_at") + list_filter = ("role", "content_type", "created_at", "updated_at") + search_fields = ("name", "original_filename", "s3_key", "public_url", "draft__id") + + +@admin.register(FilingParty) +class FilingPartyAdmin(admin.ModelAdmin): + list_display = ("id", "draft", "role", "party_type", "first_name", "last_name", "email") + list_filter = ("role", "party_type", "created_at", "updated_at") + search_fields = ("first_name", "middle_name", "last_name", "organization_name", "email", "draft__id") From 1da660de1c4c4ef2ae987b02d794bdb71e7cf1f1 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:28:20 -0400 Subject: [PATCH 03/18] Create services package --- efile_app/efile/services/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 efile_app/efile/services/__init__.py diff --git a/efile_app/efile/services/__init__.py b/efile_app/efile/services/__init__.py new file mode 100644 index 0000000..e69de29 From 34ddd2df3ad56463c85307db8c93df9bf39492ef Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:29:16 -0400 Subject: [PATCH 04/18] Add draft service module placeholder --- efile_app/efile/services/drafts.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 efile_app/efile/services/drafts.py diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py new file mode 100644 index 0000000..e01f4f3 --- /dev/null +++ b/efile_app/efile/services/drafts.py @@ -0,0 +1 @@ +"""Durable draft helpers.""" From b924397c22ef1c95981e3cb6ccf376b6297ca892 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:30:17 -0400 Subject: [PATCH 05/18] Add durable draft bridge services --- efile_app/efile/services/drafts.py | 318 ++++++++++++++++++++++++++++- 1 file changed, 317 insertions(+), 1 deletion(-) diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index e01f4f3..3fa53bc 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -1 +1,317 @@ -"""Durable draft helpers.""" +"""Bridge helpers for moving the current session-backed flow to durable drafts.""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from typing import Any + +from django.db import transaction + +from efile.models import FilingDocument, FilingDraft, FilingParty + + +CASE_FIELD_MAPPINGS: dict[str, tuple[str, ...]] = { + "existing_case": ("existing_case",), + "court_code": ("court_code", "court"), + "court_name": ("court_name",), + "case_category_code": ("case_category_code", "case_category"), + "case_category_name": ("case_category_name",), + "case_type_code": ("case_type_code", "case_type"), + "case_type_name": ("case_type_name",), + "case_subtype_code": ("case_subtype_code", "case_subtype"), + "case_subtype_name": ("case_subtype_name",), + "filing_type_code": ("filing_type_code", "filing_type", "filing_type_id"), + "filing_type_name": ("filing_type_name",), + "document_type_code": ("document_type_code", "document_type"), + "document_type_name": ("document_type_name",), + "previous_case_id": ("previous_case_id", "case_tracking_id"), + "docket_number": ("docket_number", "case_docket_id"), + "selected_payment_account_id": ("selected_payment_account_id", "selected_payment_account", "payment_account_id"), + "selected_payment_account_name": ("selected_payment_account_name", "payment_account_name"), +} + + +def first_value(data: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + value = data.get(key) + if value not in (None, ""): + return value + return "" + + +def as_string(value: Any) -> str: + return "" if value in (None, "") else str(value) + + +def authenticated_user(request): + user = getattr(request, "user", None) + return user if getattr(user, "is_authenticated", False) else None + + +def ensure_session_key(request) -> str: + if not getattr(request.session, "session_key", None): + request.session.create() + return request.session.session_key or "" + + +def get_active_draft(request) -> FilingDraft | None: + draft_id = request.session.get("filing_draft_id") + if draft_id: + try: + return FilingDraft.objects.get(pk=draft_id) + except FilingDraft.DoesNotExist: + request.session.pop("filing_draft_id", None) + request.session.modified = True + + user = authenticated_user(request) + if user: + draft = ( + FilingDraft.objects.filter(user=user, status__in=[FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR]) + .order_by("-updated_at") + .first() + ) + if draft: + request.session["filing_draft_id"] = draft.pk + request.session.modified = True + return draft + + session_key = getattr(request.session, "session_key", None) + if session_key: + draft = ( + FilingDraft.objects.filter(session_key=session_key, status__in=[FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR]) + .order_by("-updated_at") + .first() + ) + if draft: + request.session["filing_draft_id"] = draft.pk + request.session.modified = True + return draft + + return None + + +@transaction.atomic +def create_draft(request, jurisdiction: str, *, current_step: str = FilingDraft.WorkflowStep.OPTIONS) -> FilingDraft: + session_key = ensure_session_key(request) + session_id = request.session.get("session_id") or str(uuid.uuid4()) + request.session["session_id"] = session_id + request.session["jurisdiction"] = jurisdiction + + draft = FilingDraft.objects.create( + user=authenticated_user(request), + session_key=session_key, + session_id=session_id, + jurisdiction=jurisdiction, + current_step=str(current_step), + ) + request.session["filing_draft_id"] = draft.pk + request.session.modified = True + return draft + + +@transaction.atomic +def ensure_draft(request, jurisdiction: str | None = None, *, current_step: str | None = None) -> FilingDraft: + draft = get_active_draft(request) + if draft is None: + return create_draft( + request, + jurisdiction or request.session.get("jurisdiction") or "", + current_step=current_step or FilingDraft.WorkflowStep.OPTIONS, + ) + + update_fields = [] + if jurisdiction and draft.jurisdiction != jurisdiction: + draft.jurisdiction = jurisdiction + update_fields.append("jurisdiction") + if current_step and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + if update_fields: + update_fields.append("updated_at") + draft.save(update_fields=update_fields) + + request.session["filing_draft_id"] = draft.pk + request.session.modified = True + return draft + + +@transaction.atomic +def update_draft_from_case_data( + draft: FilingDraft, + case_data: Mapping[str, Any] | None, + *, + current_step: str | None = None, +) -> FilingDraft: + data = dict(case_data or {}) + update_fields = [] + + for draft_field, source_keys in CASE_FIELD_MAPPINGS.items(): + value = as_string(first_value(data, *source_keys)) + if getattr(draft, draft_field) != value: + setattr(draft, draft_field, value) + update_fields.append(draft_field) + + optional_services = data.get("optional_services") or [] + if draft.optional_services != optional_services: + draft.optional_services = optional_services + update_fields.append("optional_services") + + if draft.extra_case_data != data: + draft.extra_case_data = data + update_fields.append("extra_case_data") + + if current_step and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + + if update_fields: + update_fields.append("updated_at") + draft.save(update_fields=sorted(set(update_fields))) + + sync_parties_from_case_data(draft, data) + return draft + + +@transaction.atomic +def sync_documents_from_upload_data( + draft: FilingDraft, + upload_data: Mapping[str, Any] | None, + *, + current_step: str | None = None, +) -> FilingDraft: + data = dict(upload_data or {}) + files = dict(data.get("files") or {}) + lead_document = files.get("lead") + if lead_document: + upsert_document(draft, FilingDocument.Role.LEAD, lead_document, data, sort_order=0) + + supporting_documents = files.get("supporting") or [] + supporting_configs = data.get("supporting_documents") or [] + kept_orders = [] + for index, document in enumerate(supporting_documents): + config = supporting_configs[index] if index < len(supporting_configs) else {} + upsert_document(draft, FilingDocument.Role.SUPPORTING, document, config, sort_order=index) + kept_orders.append(index) + + if kept_orders: + FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).exclude(sort_order__in=kept_orders).delete() + else: + FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).delete() + + guesses = data.get("guesses") or {} + update_fields = [] + if guesses and draft.extracted_guesses != guesses: + draft.extracted_guesses = guesses + update_fields.append("extracted_guesses") + if current_step and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + if update_fields: + update_fields.append("updated_at") + draft.save(update_fields=update_fields) + + return draft + + +def sync_parties_from_case_data(draft: FilingDraft, case_data: Mapping[str, Any]) -> None: + petitioner = { + "party_type": as_string(first_value(case_data, "petitioner_party_type", "party_type", "determined_party_type")), + "first_name": as_string(first_value(case_data, "petitioner_first_name", "first_name")), + "last_name": as_string(first_value(case_data, "petitioner_last_name", "last_name")), + "email": as_string(first_value(case_data, "petitioner_email", "email")), + "phone": as_string(first_value(case_data, "petitioner_phone", "phone")), + "address_line_1": as_string(first_value(case_data, "petitioner_address", "address", "address_line_1")), + "metadata": {key: value for key, value in case_data.items() if key.startswith("petitioner_")}, + } + if any(petitioner.get(key) for key in ("party_type", "first_name", "last_name", "email")): + FilingParty.objects.update_or_create(draft=draft, role="petitioner", sort_order=0, defaults=petitioner) + + name_sought = { + "party_type": as_string(first_value(case_data, "new_name_party_type")), + "first_name": as_string(first_value(case_data, "new_first_name")), + "middle_name": as_string(first_value(case_data, "new_middle_name")), + "last_name": as_string(first_value(case_data, "new_last_name")), + "suffix": as_string(first_value(case_data, "new_suffix")), + "metadata": {key: value for key, value in case_data.items() if key.startswith("new_") or key.startswith("reason_")}, + } + if any(name_sought.get(key) for key in ("party_type", "first_name", "last_name")): + FilingParty.objects.update_or_create(draft=draft, role="name_sought", sort_order=0, defaults=name_sought) + + +def upsert_document( + draft: FilingDraft, + role: str, + document: Mapping[str, Any], + config: Mapping[str, Any], + *, + sort_order: int, +) -> FilingDocument: + document_data = dict(document or {}) + config_data = dict(config or {}) + defaults = { + "name": as_string(first_value(document_data, "name", "filename", "original_filename")), + "original_filename": as_string(first_value(document_data, "original_filename", "filename", "name")), + "size": positive_int(first_value(document_data, "size", "file_size")), + "content_type": as_string(first_value(document_data, "content_type", "mime_type", "type")), + "s3_key": as_string(first_value(document_data, "s3_key", "key")), + "public_url": as_string(first_value(document_data, "url", "s3_url", "file_url", "download_url")), + "filing_type_code": as_string(first_value(config_data, "filing_type", "lead_filing_type", "filing_type_code")), + "filing_type_name": as_string(first_value(config_data, "filing_type_name", "lead_filing_type_name")), + "document_type_code": as_string(first_value(config_data, "document_type", "lead_document_type", "document_type_code")), + "document_type_name": as_string(first_value(config_data, "document_type_name", "lead_document_type_name")), + "filing_component_code": as_string(first_value(config_data, "filing_component", "lead_filing_component", "filing_component_code")), + "filing_component_name": as_string(first_value(config_data, "filing_component_name", "lead_filing_component_name")), + "courtesy_copy_email": as_string(first_value(config_data, "cc_email", "lead_cc_email")), + "metadata": {"file": document_data, "config": config_data}, + } + document, _created = FilingDocument.objects.update_or_create( + draft=draft, + role=role, + sort_order=sort_order, + defaults=defaults, + ) + return document + + +def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: + if draft is None: + return None + return { + "id": draft.pk, + "jurisdiction": draft.jurisdiction, + "status": draft.status, + "current_step": draft.current_step, + "existing_case": draft.existing_case, + "court_code": draft.court_code, + "court_name": draft.court_name, + "case_category_code": draft.case_category_code, + "case_category_name": draft.case_category_name, + "case_type_code": draft.case_type_code, + "case_type_name": draft.case_type_name, + "filing_type_code": draft.filing_type_code, + "filing_type_name": draft.filing_type_name, + "document_type_code": draft.document_type_code, + "document_type_name": draft.document_type_name, + "previous_case_id": draft.previous_case_id, + "docket_number": draft.docket_number, + "selected_payment_account_id": draft.selected_payment_account_id, + "selected_payment_account_name": draft.selected_payment_account_name, + "optional_services": draft.optional_services, + "extracted_guesses": draft.extracted_guesses, + "document_count": draft.documents.count(), + "party_count": draft.parties.count(), + "created_at": draft.created_at.isoformat() if draft.created_at else None, + "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, + "submitted_at": draft.submitted_at.isoformat() if draft.submitted_at else None, + } + + +def positive_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + value = int(value) + except (TypeError, ValueError): + return None + return value if value >= 0 else None From d00aa825d9dd570ff81a8091321152b583b8fdd4 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:31:02 -0400 Subject: [PATCH 06/18] Add draft view module placeholder --- efile_app/efile/views/draft_views.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 efile_app/efile/views/draft_views.py diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py new file mode 100644 index 0000000..c10be84 --- /dev/null +++ b/efile_app/efile/views/draft_views.py @@ -0,0 +1 @@ +"""Draft views.""" From 812e3bd1540043e8ab37340e1cf7963407f86423 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:31:17 -0400 Subject: [PATCH 07/18] Add durable draft views --- efile_app/efile/views/draft_views.py | 45 +++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index c10be84..c8ff550 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -1 +1,44 @@ -"""Draft views.""" +import json +import logging + +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods + +from efile.services.drafts import create_draft, draft_snapshot, get_active_draft +from efile.utils.django_helpers import flush_cache_stay_logged_in +from efile.workflow import WorkflowStepKey, get_step_url + +logger = logging.getLogger(__name__) + + +@require_http_methods(["POST"]) +def create_draft_view(request, jurisdiction): + """Start a durable draft for the current user/session.""" + + if not request.user.is_authenticated: + return JsonResponse({"success": False, "error": "Authentication required"}, status=401) + + try: + json.loads(request.body or "{}") + except json.JSONDecodeError: + return JsonResponse({"success": False, "error": "Invalid JSON data"}, status=400) + + flush_cache_stay_logged_in(request.session) + draft = create_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) + logger.info("Created durable draft id=%s jurisdiction=%s", draft.pk, jurisdiction) + + return JsonResponse( + { + "success": True, + "data": {"filing_draft": draft_snapshot(draft)}, + "redirect_url": get_step_url(WorkflowStepKey.UPLOAD_FIRST, jurisdiction), + } + ) + + +@require_http_methods(["GET"]) +def get_current_draft_view(request): + """Return the durable draft attached to this session/user.""" + + draft = get_active_draft(request) + return JsonResponse({"success": True, "data": {"filing_draft": draft_snapshot(draft)}}) From 7a81c250d30d2909d887e6a96df84f37449c970f Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:31:23 -0400 Subject: [PATCH 08/18] Expose active durable draft on options page --- efile_app/efile/views/options.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/efile_app/efile/views/options.py b/efile_app/efile/views/options.py index f6d6182..5c7f9c0 100644 --- a/efile_app/efile/views/options.py +++ b/efile_app/efile/views/options.py @@ -1,18 +1,23 @@ from django.shortcuts import render +from django.views.decorators.csrf import ensure_csrf_cookie from efile.api.suffolk_api_views import get_tyler_token +from efile.services.drafts import draft_snapshot, get_active_draft from ..utils.case_data_utils import get_case_data from ..workflow import WorkflowStepKey, get_workflow_context +@ensure_csrf_cookie def efile_options(request, jurisdiction): """Options view that displays saved case data and provides next steps.""" # Get case data from session if request.user.is_authenticated: case_data = get_case_data(request) + active_draft = get_active_draft(request) else: case_data = {} + active_draft = None is_logged_in = request.user.is_authenticated if not get_tyler_token(request, jurisdiction): @@ -22,7 +27,8 @@ def efile_options(request, jurisdiction): context = { "is_logged_in": is_logged_in, "case_data": case_data, - "has_case_data": bool(case_data), + "filing_draft": draft_snapshot(active_draft), + "has_case_data": bool(case_data or active_draft), } context.update(get_workflow_context(WorkflowStepKey.OPTIONS, jurisdiction)) From 51f8c0063812991971d44fc81e754ce5877e60c3 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:31:35 -0400 Subject: [PATCH 09/18] Ensure lead upload is attached to a durable draft --- efile_app/efile/views/upload_first.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/efile_app/efile/views/upload_first.py b/efile_app/efile/views/upload_first.py index 1a63de6..fa538ad 100644 --- a/efile_app/efile/views/upload_first.py +++ b/efile_app/efile/views/upload_first.py @@ -4,6 +4,7 @@ from django.shortcuts import redirect, render from efile.api.suffolk_api_views import get_tyler_token +from efile.services.drafts import draft_snapshot, ensure_draft from ..utils.case_data_utils import ( get_case_classification, @@ -38,6 +39,8 @@ def efile_upload_first(request, jurisdiction): request.session["jurisdiction"] = jurisdiction request.session.modified = True + filing_draft = ensure_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) + # Could visit here from a back button press, so use upload data if any upload_data = get_upload_data(request) @@ -52,6 +55,7 @@ def efile_upload_first(request, jurisdiction): context = { "is_logged_in": is_logged_in, "upload_data": upload_data, + "filing_draft": draft_snapshot(filing_draft), "petitioner_info": petitioner_info, "name_sought_info": name_sought_info, "case_classification": case_classification, From 79308a2f04782fd5ad7ea6ff5f6dba2469e7cb47 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:31:59 -0400 Subject: [PATCH 10/18] Expose durable draft in case data API --- efile_app/efile/views/api_views.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/efile_app/efile/views/api_views.py b/efile_app/efile/views/api_views.py index 4e17ccc..800ef2e 100644 --- a/efile_app/efile/views/api_views.py +++ b/efile_app/efile/views/api_views.py @@ -8,6 +8,8 @@ from django.views import View from django.views.decorators.csrf import ensure_csrf_cookie +from efile.services.drafts import draft_snapshot, get_active_draft, update_draft_from_case_data + logger = logging.getLogger(__name__) @@ -23,9 +25,13 @@ def get(self, request): if value: data[param_to_check] = value + draft = get_active_draft(request) + if draft: + data["filing_draft"] = draft_snapshot(draft) + return JsonResponse({"success": True, "data": data}) except Exception: - logger.exception("Error retrieving case data: {e}") + logger.exception("Error retrieving case data") return JsonResponse({"success": False, "error": "Server error occurred"}, status=500) @@ -75,6 +81,10 @@ def post(self, request): request.session["case_data"] = case_data request.session.modified = True + draft = get_active_draft(request) + if draft: + update_draft_from_case_data(draft, case_data) + logger.debug(f"Saved case data to session: {case_data}") return JsonResponse({"success": True, "message": "Case data saved successfully"}) @@ -82,7 +92,7 @@ def post(self, request): except json.JSONDecodeError: return JsonResponse({"success": False, "error": "Invalid JSON data"}, status=400) except Exception: - logger.exception("Error saving case data: {e}") + logger.exception("Error saving case data") return JsonResponse({"success": False, "error": "Server error occurred"}, status=500) From 636f434652930faa55e20d321615d520809b52d9 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:32:34 -0400 Subject: [PATCH 11/18] Add durable draft routes --- efile_app/efile/urls.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 4748e21..225340c 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -5,6 +5,7 @@ from .views.api_views import get_case_data_api, get_filing_components from .views.choose_jurisdiction import choose_jurisdiction from .views.confirmation import filing_confirmation +from .views.draft_views import create_draft_view, get_current_draft_view from .views.expert_form import efile_expert_form from .views.filing_statuses import filing_statuses from .views.login import efile_login, efile_logout, efile_password_reset @@ -45,6 +46,7 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction//register/", efile_register, name="efile_register"), path("jurisdiction//password_reset/", efile_password_reset, name="efile_password_reset"), path("jurisdiction//options/", efile_options, name="efile_options"), + path("jurisdiction//drafts/", create_draft_view, name="create_draft"), path("jurisdiction//filing_statuses/", filing_statuses, name="filing_statuses"), path("jurisdiction//expert_form/", efile_expert_form, name="expert_form"), path("jurisdiction//upload_first/", efile_upload_first, name="upload_first"), @@ -55,6 +57,7 @@ def jurisdiction_homepage(request, jurisdiction): # Session API endpoints path("api/get-case-data/", get_case_data_api, name="get_case_data_api"), path("api/get-filing-components/", get_filing_components, name="get_filing_components"), + path("api/draft/", get_current_draft_view, name="get_current_draft"), path("api/save-case-data/", api_save_case_data, name="save_case_data_api"), path("api/save-upload-data/", save_upload_data_to_session, name="save_upload_data_to_session"), path("api/save-upload-data-first/", save_upload_first_data, name="save_upload_data_to_session"), From 30f7a1a267bf45e3fe5772233d7818c7b9d0a220 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:32:59 -0400 Subject: [PATCH 12/18] Start new drafts through durable draft endpoint --- efile_app/efile/templates/efile/options.html | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/efile_app/efile/templates/efile/options.html b/efile_app/efile/templates/efile/options.html index 48cc806..6eed8ea 100644 --- a/efile_app/efile/templates/efile/options.html +++ b/efile_app/efile/templates/efile/options.html @@ -97,10 +97,8 @@

{% translate "View past filings" %}

// Function to save existing_case data and redirect to expert form or case details function goToExpertForm(new_or_existing) { if (new_or_existing === "new") { - // Save the existing_case selection - makeNewCaseData().then(() => { - // For new cases, go directly to expert form with cache clearing and options page flag - window.location.href = `/jurisdiction/{{jurisdiction}}/upload_first/?clear_session=true&from_options=true`; + makeNewDraft().then((result) => { + window.location.href = result.redirect_url || `/jurisdiction/{{jurisdiction}}/upload_first/`; }); } else { // TODO(brycew): go straight to the page where the existing session was, reload everything @@ -121,9 +119,26 @@

{% translate "View past filings" %}

return false; } + async function makeNewDraft() { + try { + sessionStorage.removeItem('session_id'); + const result = await apiUtils.post(`/jurisdiction/{{jurisdiction}}/drafts/`, {}); + if (!result.success) { + throw new Error(result.error || 'Unable to create draft'); + } + return result; + } catch (error) { + console.warn('Error creating draft; falling back to session start:', error); + await makeNewCaseData(); + return { + redirect_url: `/jurisdiction/{{jurisdiction}}/upload_first/?clear_session=true&from_options=true` + }; + } + } + // Function to save existing_case data to the session async function makeNewCaseData() { - // Save to session storage as immediate fallback + // Save to session storage as immediate fallback for the legacy start path let new_uuid = self.crypto.randomUUID(); sessionStorage.setItem('session_id', new_uuid); try { From f6a238c916dda191798a1327e61318f4bc8bdb0b Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:33:07 -0400 Subject: [PATCH 13/18] Create migrations package --- efile_app/efile/migrations/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 efile_app/efile/migrations/__init__.py diff --git a/efile_app/efile/migrations/__init__.py b/efile_app/efile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From 2bff193ef3c952431486170466bc6753525ac785 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:33:40 -0400 Subject: [PATCH 14/18] Add initial migration for durable draft models --- efile_app/efile/migrations/0001_initial.py | 271 +++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 efile_app/efile/migrations/0001_initial.py diff --git a/efile_app/efile/migrations/0001_initial.py b/efile_app/efile/migrations/0001_initial.py new file mode 100644 index 0000000..50f4d5b --- /dev/null +++ b/efile_app/efile/migrations/0001_initial.py @@ -0,0 +1,271 @@ +# Generated by hand for the durable filing draft model foundation. + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("password", models.CharField(max_length=128, verbose_name="password")), + ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), + ), + ("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")), + ("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")), + ("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text=( + "Designates whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + verbose_name="active", + ), + ), + ("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")), + ("tyler_jurisdiction", models.CharField(max_length=20)), + ("tyler_user_id", models.CharField(blank=True, max_length=100, null=True)), + ("email_updates", models.BooleanField(default=False)), + ("text_updates", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text=( + "The groups this user belongs to. A user will get all permissions granted to each of " + "their groups." + ), + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "User Profile", + "verbose_name_plural": "User Profiles", + }, + ), + migrations.CreateModel( + name="FilingDraft", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("session_key", models.CharField(blank=True, db_index=True, max_length=80)), + ("session_id", models.CharField(blank=True, db_index=True, max_length=100)), + ("jurisdiction", models.CharField(db_index=True, max_length=40)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitting", "Submitting"), + ("submitted", "Submitted"), + ("error", "Error"), + ("abandoned", "Abandoned"), + ], + db_index=True, + default="draft", + max_length=20, + ), + ), + ( + "current_step", + models.CharField( + choices=[ + ("options", "Options"), + ("upload_first", "Upload lead document"), + ("case_information", "Case information"), + ("documents", "Documents"), + ("payment", "Payment"), + ("review", "Review"), + ("confirmation", "Confirmation"), + ], + default="options", + max_length=64, + ), + ), + ("existing_case", models.CharField(blank=True, max_length=20)), + ("court_code", models.CharField(blank=True, max_length=100)), + ("court_name", models.CharField(blank=True, max_length=255)), + ("case_category_code", models.CharField(blank=True, max_length=100)), + ("case_category_name", models.CharField(blank=True, max_length=255)), + ("case_type_code", models.CharField(blank=True, max_length=100)), + ("case_type_name", models.CharField(blank=True, max_length=255)), + ("case_subtype_code", models.CharField(blank=True, max_length=100)), + ("case_subtype_name", models.CharField(blank=True, max_length=255)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("previous_case_id", models.CharField(blank=True, max_length=255)), + ("docket_number", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_id", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_name", models.CharField(blank=True, max_length=255)), + ("optional_services", models.JSONField(blank=True, default=list)), + ("extracted_guesses", models.JSONField(blank=True, default=dict)), + ("extra_case_data", models.JSONField(blank=True, default=dict)), + ("submission_response", models.JSONField(blank=True, default=dict)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="filing_drafts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + "indexes": [ + models.Index(fields=["user", "status"], name="efile_filing_user_id_3f2f47_idx"), + models.Index(fields=["jurisdiction", "status"], name="efile_filing_jurisdi_7b6b99_idx"), + models.Index(fields=["status", "updated_at"], name="efile_filing_status_4f8dcf_idx"), + ], + }, + ), + migrations.CreateModel( + name="FilingDocument", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(choices=[("lead", "Lead document"), ("supporting", "Supporting document")], max_length=20)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("name", models.CharField(blank=True, max_length=255)), + ("original_filename", models.CharField(blank=True, max_length=255)), + ("size", models.PositiveBigIntegerField(blank=True, null=True)), + ("content_type", models.CharField(blank=True, max_length=255)), + ("s3_key", models.CharField(blank=True, max_length=1024)), + ("public_url", models.URLField(blank=True, max_length=2048)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("filing_component_code", models.CharField(blank=True, max_length=100)), + ("filing_component_name", models.CharField(blank=True, max_length=255)), + ("courtesy_copy_email", models.EmailField(blank=True, max_length=254)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="documents", + to="efile.filingdraft", + ), + ), + ], + options={ + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="FilingParty", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(max_length=50)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("party_type", models.CharField(blank=True, max_length=100)), + ("external_party_id", models.CharField(blank=True, max_length=255)), + ("first_name", models.CharField(blank=True, max_length=100)), + ("middle_name", models.CharField(blank=True, max_length=100)), + ("last_name", models.CharField(blank=True, max_length=100)), + ("suffix", models.CharField(blank=True, max_length=50)), + ("organization_name", models.CharField(blank=True, max_length=255)), + ("email", models.EmailField(blank=True, max_length=254)), + ("phone", models.CharField(blank=True, max_length=50)), + ("address_line_1", models.CharField(blank=True, max_length=255)), + ("address_line_2", models.CharField(blank=True, max_length=255)), + ("city", models.CharField(blank=True, max_length=100)), + ("state", models.CharField(blank=True, max_length=50)), + ("zip_code", models.CharField(blank=True, max_length=20)), + ("country", models.CharField(blank=True, default="US", max_length=2)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="parties", + to="efile.filingdraft", + ), + ), + ], + options={ + "verbose_name_plural": "Filing parties", + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.AddConstraint( + model_name="filingdocument", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), + name="unique_document_order_per_draft_role", + ), + ), + migrations.AddConstraint( + model_name="filingparty", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), name="unique_party_order_per_draft_role" + ), + ), + ] From ca5fe5e20dc90920e9385a67a6ff7ba7d6ab4696 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:34:02 -0400 Subject: [PATCH 15/18] Add durable draft model and service tests --- efile_app/efile/tests/test_durable_drafts.py | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 efile_app/efile/tests/test_durable_drafts.py diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py new file mode 100644 index 0000000..e97edb9 --- /dev/null +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -0,0 +1,142 @@ +import json + +import pytest +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.drafts import draft_snapshot, sync_documents_from_upload_data, update_draft_from_case_data + + +@pytest.mark.django_db +def test_update_draft_from_case_data_normalizes_known_fields(): + draft = FilingDraft.objects.create(jurisdiction="illinois") + case_data = { + "court": "cook:cd", + "court_name": "Cook County Circuit Court", + "case_category": "MR", + "case_category_name": "Miscellaneous Remedy", + "case_type": "Name Change", + "case_type_name": "Change of Name", + "filing_type": "motion", + "filing_type_name": "Motion", + "document_type": "petition", + "document_type_name": "Petition", + "selected_payment_account": "pay-123", + "selected_payment_account_name": "Card ending in 4242", + "optional_services": ["certified_copy"], + "petitioner_first_name": "Ada", + "petitioner_last_name": "Lovelace", + "petitioner_email": "ada@example.com", + "new_first_name": "Augusta Ada", + "new_last_name": "Lovelace", + } + + update_draft_from_case_data(draft, case_data, current_step=FilingDraft.WorkflowStep.CASE_INFORMATION) + draft.refresh_from_db() + + assert draft.current_step == FilingDraft.WorkflowStep.CASE_INFORMATION + assert draft.court_code == "cook:cd" + assert draft.case_category_code == "MR" + assert draft.case_type_code == "Name Change" + assert draft.filing_type_code == "motion" + assert draft.document_type_code == "petition" + assert draft.selected_payment_account_id == "pay-123" + assert draft.optional_services == ["certified_copy"] + assert draft.extra_case_data["petitioner_first_name"] == "Ada" + + petitioner = FilingParty.objects.get(draft=draft, role="petitioner") + assert petitioner.first_name == "Ada" + assert petitioner.last_name == "Lovelace" + assert petitioner.email == "ada@example.com" + + name_sought = FilingParty.objects.get(draft=draft, role="name_sought") + assert name_sought.first_name == "Augusta Ada" + + +@pytest.mark.django_db +def test_sync_documents_from_upload_data_creates_lead_and_supporting_documents(): + draft = FilingDraft.objects.create(jurisdiction="illinois") + upload_data = { + "files": { + "lead": { + "name": "petition.pdf", + "url": "https://example.com/petition.pdf", + "s3_key": "drafts/petition.pdf", + "size": 1234, + "content_type": "application/pdf", + }, + "supporting": [ + { + "name": "order.pdf", + "url": "https://example.com/order.pdf", + "size": 4321, + "content_type": "application/pdf", + } + ], + }, + "guesses": {"court": "Cook County"}, + "lead_filing_type": "efile", + "lead_document_type": "petition", + "lead_filing_component": "lead", + "supporting_documents": [ + { + "filing_type": "attachment", + "document_type": "exhibit", + "filing_component": "supporting", + "cc_email": "copy@example.com", + } + ], + } + + sync_documents_from_upload_data(draft, upload_data, current_step=FilingDraft.WorkflowStep.DOCUMENTS) + draft.refresh_from_db() + + assert draft.current_step == FilingDraft.WorkflowStep.DOCUMENTS + assert draft.extracted_guesses == {"court": "Cook County"} + + lead = FilingDocument.objects.get(draft=draft, role=FilingDocument.Role.LEAD) + assert lead.name == "petition.pdf" + assert lead.s3_key == "drafts/petition.pdf" + assert lead.filing_type_code == "efile" + + supporting = FilingDocument.objects.get(draft=draft, role=FilingDocument.Role.SUPPORTING) + assert supporting.name == "order.pdf" + assert supporting.document_type_code == "exhibit" + assert supporting.courtesy_copy_email == "copy@example.com" + + +@pytest.mark.django_db +def test_draft_snapshot_is_json_serializable(): + draft = FilingDraft.objects.create(jurisdiction="illinois", court_code="cook:cd") + + snapshot = draft_snapshot(draft) + + assert snapshot["id"] == draft.pk + assert snapshot["court_code"] == "cook:cd" + json.dumps(snapshot) + + +@pytest.mark.django_db +def test_create_draft_view_creates_durable_draft(client, django_user_model): + user = django_user_model.objects.create_user( + username="testuser", + password="testpass123", + tyler_jurisdiction="illinois", + ) + client.force_login(user) + + response = client.post( + reverse("create_draft", kwargs={"jurisdiction": "illinois"}), + data={}, + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["redirect_url"] == reverse("upload_first", kwargs={"jurisdiction": "illinois"}) + + draft = FilingDraft.objects.get(user=user) + assert draft.jurisdiction == "illinois" + assert draft.current_step == FilingDraft.WorkflowStep.UPLOAD_FIRST + assert payload["data"]["filing_draft"]["id"] == draft.pk From a982d253556bf0b16a938caaf0a9cf261dd09ace Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 17:34:12 -0400 Subject: [PATCH 16/18] Document durable draft model migration path --- docs/filing-draft-data-model.md | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/filing-draft-data-model.md diff --git a/docs/filing-draft-data-model.md b/docs/filing-draft-data-model.md new file mode 100644 index 0000000..bc13d38 --- /dev/null +++ b/docs/filing-draft-data-model.md @@ -0,0 +1,34 @@ +# Filing draft data model foundation + +This PR introduces the durable filing aggregate that future filing-flow PRs can migrate toward. + +## New source-of-truth models + +- `FilingDraft`: one filing workflow instance. It owns jurisdiction, status, current workflow step, case classification, existing-case identifiers, selected payment account, extracted guesses, temporary extra case data, and submission response. +- `FilingDocument`: one uploaded lead or supporting document. It owns S3/public URL metadata, file metadata, filing/document/component codes and names, courtesy copy email, and document order. +- `FilingParty`: one party/person associated with the draft. It owns role, party type, contact, name, and address fields. + +The model intentionally keeps `extra_case_data` and document/party `metadata` JSON fields as temporary escape hatches so the UI can move off session blobs incrementally without blocking on a perfect schema. + +## Backwards-compatible bridge + +The existing session-backed flow still works. This PR adds service helpers that can shadow-write the current session shapes into the durable models: + +- `create_draft()` +- `ensure_draft()` +- `update_draft_from_case_data()` +- `sync_documents_from_upload_data()` +- `draft_snapshot()` + +The options/start flow now creates a `FilingDraft` through `POST /jurisdiction//drafts/` and redirects to the workflow's first document-upload step. The old session start path remains as a browser fallback. + +## Future migration sequence + +This is intended as the foundation for follow-up PRs: + +1. Update the remaining case-data save APIs to use `update_draft_from_case_data()`. +2. Update upload APIs to use `sync_documents_from_upload_data()`. +3. Move payment selection into explicit draft fields. +4. Render review from `FilingDraft`, `FilingDocument`, and `FilingParty`. +5. Move final submission to a draft-backed submission service. +6. Remove legacy arbitrary session-merge APIs and localStorage/sessionStorage persistence. From 8c16ad90227481af1128d0d14531bf891d9d5a1d Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 19:08:23 -0400 Subject: [PATCH 17/18] Refactor PR, add clear django db migration --- docs/filing-draft-data-model.md | 26 +- efile_app/efile/admin.py | 1 - efile_app/efile/migrations/0001_initial.py | 180 +--------- .../efile/migrations/0002_filing_drafts.py | 185 +++++++++++ efile_app/efile/models.py | 33 +- efile_app/efile/services/current_drafts.py | 105 ++++++ efile_app/efile/services/drafts.py | 310 +++--------------- .../efile/services/legacy_draft_bridge.py | 263 +++++++++++++++ efile_app/efile/tests/test_durable_drafts.py | 142 +++++++- efile_app/efile/views/api_views.py | 10 +- efile_app/efile/views/draft_views.py | 14 +- efile_app/efile/views/options.py | 5 +- efile_app/efile/views/session_api.py | 11 + efile_app/efile/views/upload_first.py | 5 +- efile_app/efile/workflow.py | 6 + fly.toml | 2 +- 16 files changed, 812 insertions(+), 486 deletions(-) create mode 100644 efile_app/efile/migrations/0002_filing_drafts.py create mode 100644 efile_app/efile/services/current_drafts.py create mode 100644 efile_app/efile/services/legacy_draft_bridge.py diff --git a/docs/filing-draft-data-model.md b/docs/filing-draft-data-model.md index bc13d38..e22681f 100644 --- a/docs/filing-draft-data-model.md +++ b/docs/filing-draft-data-model.md @@ -10,25 +10,29 @@ This PR introduces the durable filing aggregate that future filing-flow PRs can The model intentionally keeps `extra_case_data` and document/party `metadata` JSON fields as temporary escape hatches so the UI can move off session blobs incrementally without blocking on a perfect schema. -## Backwards-compatible bridge +## Service boundaries -The existing session-backed flow still works. This PR adds service helpers that can shadow-write the current session shapes into the durable models: +The durable draft code is split into three layers: -- `create_draft()` -- `ensure_draft()` -- `update_draft_from_case_data()` -- `sync_documents_from_upload_data()` -- `draft_snapshot()` +- `services/drafts.py` contains request-independent operations on durable models. +- `services/current_drafts.py` resolves the current browser's draft while enforcing ownership and jurisdiction. The session contains only the current draft ID; it is not a state store. +- `services/legacy_draft_bridge.py` is the temporary compatibility adapter that translates the old `case_data` and `upload_data` blobs. Only legacy session endpoints import it, so it can be removed without changing the durable service. -The options/start flow now creates a `FilingDraft` through `POST /jurisdiction//drafts/` and redirects to the workflow's first document-upload step. The old session start path remains as a browser fallback. +The options/start flow creates a `FilingDraft` through `POST /jurisdiction//drafts/` and redirects to the workflow's first document-upload step. The old session start path remains as a browser fallback. While that flow remains, its actual case and upload save endpoints shadow-write through the compatibility adapter. + +Drafts require an authenticated owner. Every current-draft lookup verifies that owner, active status, and, when known, jurisdiction before returning the object. + +## Migration rollout + +`efile` previously had no migrations even though existing environments may already have the `UserProfile` table. Migration `0001` therefore contains only that baseline model, and migration `0002` creates the new filing tables. Existing environments must run `migrate --fake-initial`; the Fly release command includes that option. Fresh databases apply both migrations normally. ## Future migration sequence This is intended as the foundation for follow-up PRs: -1. Update the remaining case-data save APIs to use `update_draft_from_case_data()`. -2. Update upload APIs to use `sync_documents_from_upload_data()`. +1. Replace legacy case-data endpoints with typed draft updates. +2. Replace legacy upload endpoints with typed document updates. 3. Move payment selection into explicit draft fields. 4. Render review from `FilingDraft`, `FilingDocument`, and `FilingParty`. 5. Move final submission to a draft-backed submission service. -6. Remove legacy arbitrary session-merge APIs and localStorage/sessionStorage persistence. +6. Remove `legacy_draft_bridge.py`, arbitrary session-merge APIs, and browser storage persistence. diff --git a/efile_app/efile/admin.py b/efile_app/efile/admin.py index 9574799..ee98bef 100644 --- a/efile_app/efile/admin.py +++ b/efile_app/efile/admin.py @@ -60,7 +60,6 @@ class FilingDraftAdmin(admin.ModelAdmin): list_filter = ("jurisdiction", "status", "current_step", "created_at", "updated_at") search_fields = ( "id", - "session_id", "court_code", "court_name", "case_type_code", diff --git a/efile_app/efile/migrations/0001_initial.py b/efile_app/efile/migrations/0001_initial.py index 50f4d5b..07c31ed 100644 --- a/efile_app/efile/migrations/0001_initial.py +++ b/efile_app/efile/migrations/0001_initial.py @@ -1,13 +1,18 @@ -# Generated by hand for the durable filing draft model foundation. +# Generated by Django 5.2.5 on 2026-06-29 +import django.contrib.auth.models import django.contrib.auth.validators -import django.db.models.deletion import django.utils.timezone -from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + """Baseline the pre-existing custom user table. + + Existing environments should apply this with ``migrate --fake-initial``; + fresh databases create the table normally. + """ + initial = True dependencies = [ @@ -99,173 +104,8 @@ class Migration(migrations.Migration): "verbose_name": "User Profile", "verbose_name_plural": "User Profiles", }, - ), - migrations.CreateModel( - name="FilingDraft", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("session_key", models.CharField(blank=True, db_index=True, max_length=80)), - ("session_id", models.CharField(blank=True, db_index=True, max_length=100)), - ("jurisdiction", models.CharField(db_index=True, max_length=40)), - ( - "status", - models.CharField( - choices=[ - ("draft", "Draft"), - ("submitting", "Submitting"), - ("submitted", "Submitted"), - ("error", "Error"), - ("abandoned", "Abandoned"), - ], - db_index=True, - default="draft", - max_length=20, - ), - ), - ( - "current_step", - models.CharField( - choices=[ - ("options", "Options"), - ("upload_first", "Upload lead document"), - ("case_information", "Case information"), - ("documents", "Documents"), - ("payment", "Payment"), - ("review", "Review"), - ("confirmation", "Confirmation"), - ], - default="options", - max_length=64, - ), - ), - ("existing_case", models.CharField(blank=True, max_length=20)), - ("court_code", models.CharField(blank=True, max_length=100)), - ("court_name", models.CharField(blank=True, max_length=255)), - ("case_category_code", models.CharField(blank=True, max_length=100)), - ("case_category_name", models.CharField(blank=True, max_length=255)), - ("case_type_code", models.CharField(blank=True, max_length=100)), - ("case_type_name", models.CharField(blank=True, max_length=255)), - ("case_subtype_code", models.CharField(blank=True, max_length=100)), - ("case_subtype_name", models.CharField(blank=True, max_length=255)), - ("filing_type_code", models.CharField(blank=True, max_length=100)), - ("filing_type_name", models.CharField(blank=True, max_length=255)), - ("document_type_code", models.CharField(blank=True, max_length=100)), - ("document_type_name", models.CharField(blank=True, max_length=255)), - ("previous_case_id", models.CharField(blank=True, max_length=255)), - ("docket_number", models.CharField(blank=True, max_length=255)), - ("selected_payment_account_id", models.CharField(blank=True, max_length=255)), - ("selected_payment_account_name", models.CharField(blank=True, max_length=255)), - ("optional_services", models.JSONField(blank=True, default=list)), - ("extracted_guesses", models.JSONField(blank=True, default=dict)), - ("extra_case_data", models.JSONField(blank=True, default=dict)), - ("submission_response", models.JSONField(blank=True, default=dict)), - ("submitted_at", models.DateTimeField(blank=True, null=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="filing_drafts", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "ordering": ["-updated_at"], - "indexes": [ - models.Index(fields=["user", "status"], name="efile_filing_user_id_3f2f47_idx"), - models.Index(fields=["jurisdiction", "status"], name="efile_filing_jurisdi_7b6b99_idx"), - models.Index(fields=["status", "updated_at"], name="efile_filing_status_4f8dcf_idx"), - ], - }, - ), - migrations.CreateModel( - name="FilingDocument", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("role", models.CharField(choices=[("lead", "Lead document"), ("supporting", "Supporting document")], max_length=20)), - ("sort_order", models.PositiveIntegerField(default=0)), - ("name", models.CharField(blank=True, max_length=255)), - ("original_filename", models.CharField(blank=True, max_length=255)), - ("size", models.PositiveBigIntegerField(blank=True, null=True)), - ("content_type", models.CharField(blank=True, max_length=255)), - ("s3_key", models.CharField(blank=True, max_length=1024)), - ("public_url", models.URLField(blank=True, max_length=2048)), - ("filing_type_code", models.CharField(blank=True, max_length=100)), - ("filing_type_name", models.CharField(blank=True, max_length=255)), - ("document_type_code", models.CharField(blank=True, max_length=100)), - ("document_type_name", models.CharField(blank=True, max_length=255)), - ("filing_component_code", models.CharField(blank=True, max_length=100)), - ("filing_component_name", models.CharField(blank=True, max_length=255)), - ("courtesy_copy_email", models.EmailField(blank=True, max_length=254)), - ("metadata", models.JSONField(blank=True, default=dict)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "draft", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="documents", - to="efile.filingdraft", - ), - ), - ], - options={ - "ordering": ["role", "sort_order", "created_at"], - }, - ), - migrations.CreateModel( - name="FilingParty", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("role", models.CharField(max_length=50)), - ("sort_order", models.PositiveIntegerField(default=0)), - ("party_type", models.CharField(blank=True, max_length=100)), - ("external_party_id", models.CharField(blank=True, max_length=255)), - ("first_name", models.CharField(blank=True, max_length=100)), - ("middle_name", models.CharField(blank=True, max_length=100)), - ("last_name", models.CharField(blank=True, max_length=100)), - ("suffix", models.CharField(blank=True, max_length=50)), - ("organization_name", models.CharField(blank=True, max_length=255)), - ("email", models.EmailField(blank=True, max_length=254)), - ("phone", models.CharField(blank=True, max_length=50)), - ("address_line_1", models.CharField(blank=True, max_length=255)), - ("address_line_2", models.CharField(blank=True, max_length=255)), - ("city", models.CharField(blank=True, max_length=100)), - ("state", models.CharField(blank=True, max_length=50)), - ("zip_code", models.CharField(blank=True, max_length=20)), - ("country", models.CharField(blank=True, default="US", max_length=2)), - ("metadata", models.JSONField(blank=True, default=dict)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "draft", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="parties", - to="efile.filingdraft", - ), - ), + managers=[ + ("objects", django.contrib.auth.models.UserManager()), ], - options={ - "verbose_name_plural": "Filing parties", - "ordering": ["role", "sort_order", "created_at"], - }, - ), - migrations.AddConstraint( - model_name="filingdocument", - constraint=models.UniqueConstraint( - fields=("draft", "role", "sort_order"), - name="unique_document_order_per_draft_role", - ), - ), - migrations.AddConstraint( - model_name="filingparty", - constraint=models.UniqueConstraint( - fields=("draft", "role", "sort_order"), name="unique_party_order_per_draft_role" - ), ), ] diff --git a/efile_app/efile/migrations/0002_filing_drafts.py b/efile_app/efile/migrations/0002_filing_drafts.py new file mode 100644 index 0000000..aec407d --- /dev/null +++ b/efile_app/efile/migrations/0002_filing_drafts.py @@ -0,0 +1,185 @@ +# Generated by Django 5.2.5 on 2026-06-29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FilingDraft", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("jurisdiction", models.CharField(db_index=True, max_length=40)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitting", "Submitting"), + ("submitted", "Submitted"), + ("error", "Error"), + ("abandoned", "Abandoned"), + ], + db_index=True, + default="draft", + max_length=20, + ), + ), + ( + "current_step", + models.CharField( + choices=[ + ("options", "Options"), + ("upload_first", "Upload lead document"), + ("case_information", "Case information"), + ("documents", "Documents"), + ("payment", "Payment"), + ("review", "Review"), + ("confirmation", "Confirmation"), + ], + default="options", + max_length=64, + ), + ), + ("existing_case", models.CharField(blank=True, max_length=20)), + ("court_code", models.CharField(blank=True, max_length=100)), + ("court_name", models.CharField(blank=True, max_length=255)), + ("case_category_code", models.CharField(blank=True, max_length=100)), + ("case_category_name", models.CharField(blank=True, max_length=255)), + ("case_type_code", models.CharField(blank=True, max_length=100)), + ("case_type_name", models.CharField(blank=True, max_length=255)), + ("case_subtype_code", models.CharField(blank=True, max_length=100)), + ("case_subtype_name", models.CharField(blank=True, max_length=255)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("previous_case_id", models.CharField(blank=True, max_length=255)), + ("docket_number", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_id", models.CharField(blank=True, max_length=255)), + ("selected_payment_account_name", models.CharField(blank=True, max_length=255)), + ("optional_services", models.JSONField(blank=True, default=list)), + ("extracted_guesses", models.JSONField(blank=True, default=dict)), + ("extra_case_data", models.JSONField(blank=True, default=dict)), + ("submission_response", models.JSONField(blank=True, default=dict)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="filing_drafts", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + "indexes": [ + models.Index(fields=["user", "status"], name="draft_user_status_idx"), + models.Index(fields=["jurisdiction", "status"], name="draft_jurisdiction_status_idx"), + models.Index(fields=["status", "updated_at"], name="draft_status_updated_idx"), + ], + }, + ), + migrations.CreateModel( + name="FilingDocument", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "role", + models.CharField( + choices=[("lead", "Lead document"), ("supporting", "Supporting document")], + max_length=20, + ), + ), + ("sort_order", models.PositiveIntegerField(default=0)), + ("name", models.CharField(blank=True, max_length=255)), + ("original_filename", models.CharField(blank=True, max_length=255)), + ("size", models.PositiveBigIntegerField(blank=True, null=True)), + ("content_type", models.CharField(blank=True, max_length=255)), + ("s3_key", models.CharField(blank=True, max_length=1024)), + ("public_url", models.URLField(blank=True, max_length=2048)), + ("filing_type_code", models.CharField(blank=True, max_length=100)), + ("filing_type_name", models.CharField(blank=True, max_length=255)), + ("document_type_code", models.CharField(blank=True, max_length=100)), + ("document_type_name", models.CharField(blank=True, max_length=255)), + ("filing_component_code", models.CharField(blank=True, max_length=100)), + ("filing_component_name", models.CharField(blank=True, max_length=255)), + ("courtesy_copy_email", models.EmailField(blank=True, max_length=254)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="documents", + to="efile.filingdraft", + ), + ), + ], + options={ + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.CreateModel( + name="FilingParty", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("role", models.CharField(max_length=50)), + ("sort_order", models.PositiveIntegerField(default=0)), + ("party_type", models.CharField(blank=True, max_length=100)), + ("external_party_id", models.CharField(blank=True, max_length=255)), + ("first_name", models.CharField(blank=True, max_length=100)), + ("middle_name", models.CharField(blank=True, max_length=100)), + ("last_name", models.CharField(blank=True, max_length=100)), + ("suffix", models.CharField(blank=True, max_length=50)), + ("organization_name", models.CharField(blank=True, max_length=255)), + ("email", models.EmailField(blank=True, max_length=254)), + ("phone", models.CharField(blank=True, max_length=50)), + ("address_line_1", models.CharField(blank=True, max_length=255)), + ("address_line_2", models.CharField(blank=True, max_length=255)), + ("city", models.CharField(blank=True, max_length=100)), + ("state", models.CharField(blank=True, max_length=50)), + ("zip_code", models.CharField(blank=True, max_length=20)), + ("country", models.CharField(blank=True, default="US", max_length=2)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "draft", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="parties", + to="efile.filingdraft", + ), + ), + ], + options={ + "verbose_name_plural": "Filing parties", + "ordering": ["role", "sort_order", "created_at"], + }, + ), + migrations.AddConstraint( + model_name="filingdocument", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), + name="unique_document_order_per_draft_role", + ), + ), + migrations.AddConstraint( + model_name="filingparty", + constraint=models.UniqueConstraint( + fields=("draft", "role", "sort_order"), + name="unique_party_order_per_draft_role", + ), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 873f171..7e52f7c 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -4,6 +4,8 @@ from django.db import models from django.utils import timezone +from efile.workflow import WorkflowStepKey, get_workflow_step_choices + class UserProfile(AbstractUser): """ @@ -40,27 +42,18 @@ class Status(models.TextChoices): ERROR = "error", "Error" ABANDONED = "abandoned", "Abandoned" - class WorkflowStep(models.TextChoices): - OPTIONS = "options", "Options" - UPLOAD_FIRST = "upload_first", "Upload lead document" - CASE_INFORMATION = "case_information", "Case information" - DOCUMENTS = "documents", "Documents" - PAYMENT = "payment", "Payment" - REVIEW = "review", "Review" - CONFIRMATION = "confirmation", "Confirmation" - user = models.ForeignKey( settings.AUTH_USER_MODEL, - blank=True, - null=True, on_delete=models.CASCADE, related_name="filing_drafts", ) - session_key = models.CharField(max_length=80, blank=True, db_index=True) - session_id = models.CharField(max_length=100, blank=True, db_index=True) jurisdiction = models.CharField(max_length=40, db_index=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True) - current_step = models.CharField(max_length=64, choices=WorkflowStep.choices, default=WorkflowStep.OPTIONS) + current_step = models.CharField( + max_length=64, + choices=get_workflow_step_choices(), + default=WorkflowStepKey.OPTIONS, + ) existing_case = models.CharField(max_length=20, blank=True) court_code = models.CharField(max_length=100, blank=True) @@ -94,9 +87,9 @@ class WorkflowStep(models.TextChoices): class Meta: ordering = ["-updated_at"] indexes = [ - models.Index(fields=["user", "status"]), - models.Index(fields=["jurisdiction", "status"]), - models.Index(fields=["status", "updated_at"]), + models.Index(fields=["user", "status"], name="draft_user_status_idx"), + models.Index(fields=["jurisdiction", "status"], name="draft_jurisdiction_status_idx"), + models.Index(fields=["status", "updated_at"], name="draft_status_updated_idx"), ] def __str__(self): @@ -104,7 +97,7 @@ def __str__(self): def mark_submitted(self, response_data): self.status = self.Status.SUBMITTED - self.current_step = self.WorkflowStep.CONFIRMATION + self.current_step = WorkflowStepKey.CONFIRMATION self.submission_response = response_data or {} self.submitted_at = timezone.now() self.save(update_fields=["status", "current_step", "submission_response", "submitted_at", "updated_at"]) @@ -149,7 +142,9 @@ class Role(models.TextChoices): class Meta: ordering = ["role", "sort_order", "created_at"] constraints = [ - models.UniqueConstraint(fields=["draft", "role", "sort_order"], name="unique_document_order_per_draft_role"), + models.UniqueConstraint( + fields=["draft", "role", "sort_order"], name="unique_document_order_per_draft_role" + ), ] def __str__(self): diff --git a/efile_app/efile/services/current_drafts.py b/efile_app/efile/services/current_drafts.py new file mode 100644 index 0000000..00c84e7 --- /dev/null +++ b/efile_app/efile/services/current_drafts.py @@ -0,0 +1,105 @@ +"""Select the durable filing draft associated with the current request.""" + +from __future__ import annotations + +from django.db import transaction + +from efile.models import FilingDraft +from efile.services.drafts import create_draft, get_active_draft, set_current_step +from efile.workflow import WorkflowStepKey + +CURRENT_DRAFT_SESSION_KEY = "filing_draft_id" + + +def _authenticated_user(request): + user = getattr(request, "user", None) + return user if getattr(user, "is_authenticated", False) else None + + +def attach_current_draft(request, draft: FilingDraft) -> None: + """Remember which durable draft this browser is editing.""" + + request.session[CURRENT_DRAFT_SESSION_KEY] = draft.pk + request.session["jurisdiction"] = draft.jurisdiction + request.session.modified = True + + +def clear_current_draft(request) -> None: + if CURRENT_DRAFT_SESSION_KEY in request.session: + del request.session[CURRENT_DRAFT_SESSION_KEY] + request.session.modified = True + + +def get_current_draft( + request, + *, + jurisdiction: str | None = None, + resume_latest: bool = True, +) -> FilingDraft | None: + """Resolve the current user's draft without trusting a bare session ID. + + The session only stores a pointer. Ownership, active status, and (when + supplied) jurisdiction are enforced on every lookup. + """ + + user = _authenticated_user(request) + if user is None: + clear_current_draft(request) + return None + + draft_id = request.session.get(CURRENT_DRAFT_SESSION_KEY) + if draft_id is not None: + try: + draft_id = int(draft_id) + except (TypeError, ValueError): + clear_current_draft(request) + draft_id = None + + if draft_id is not None: + draft = get_active_draft(user=user, draft_id=draft_id, jurisdiction=jurisdiction) + if draft is not None: + return draft + clear_current_draft(request) + + if not resume_latest: + return None + + draft = get_active_draft(user=user, jurisdiction=jurisdiction) + if draft is not None: + attach_current_draft(request, draft) + return draft + + +@transaction.atomic +def create_current_draft( + request, + jurisdiction: str, + *, + current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, +) -> FilingDraft: + draft = create_draft( + user=_authenticated_user(request), + jurisdiction=jurisdiction, + current_step=current_step, + ) + attach_current_draft(request, draft) + return draft + + +@transaction.atomic +def ensure_current_draft( + request, + jurisdiction: str, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + draft = get_current_draft(request, jurisdiction=jurisdiction) + if draft is None: + return create_current_draft( + request, + jurisdiction, + current_step=current_step or WorkflowStepKey.OPTIONS, + ) + if current_step is not None: + set_current_step(draft, current_step) + return draft diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 3fa53bc..9bb1344 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -1,280 +1,80 @@ -"""Bridge helpers for moving the current session-backed flow to durable drafts.""" +"""Operations on the durable filing draft aggregate. + +This module deliberately has no dependency on HTTP requests, sessions, or the +legacy session data shapes. Request/session selection lives in +``current_drafts``; legacy data translation lives in ``legacy_draft_bridge``. +""" from __future__ import annotations -import uuid -from collections.abc import Mapping from typing import Any from django.db import transaction +from django.db.models import QuerySet -from efile.models import FilingDocument, FilingDraft, FilingParty - - -CASE_FIELD_MAPPINGS: dict[str, tuple[str, ...]] = { - "existing_case": ("existing_case",), - "court_code": ("court_code", "court"), - "court_name": ("court_name",), - "case_category_code": ("case_category_code", "case_category"), - "case_category_name": ("case_category_name",), - "case_type_code": ("case_type_code", "case_type"), - "case_type_name": ("case_type_name",), - "case_subtype_code": ("case_subtype_code", "case_subtype"), - "case_subtype_name": ("case_subtype_name",), - "filing_type_code": ("filing_type_code", "filing_type", "filing_type_id"), - "filing_type_name": ("filing_type_name",), - "document_type_code": ("document_type_code", "document_type"), - "document_type_name": ("document_type_name",), - "previous_case_id": ("previous_case_id", "case_tracking_id"), - "docket_number": ("docket_number", "case_docket_id"), - "selected_payment_account_id": ("selected_payment_account_id", "selected_payment_account", "payment_account_id"), - "selected_payment_account_name": ("selected_payment_account_name", "payment_account_name"), -} - - -def first_value(data: Mapping[str, Any], *keys: str) -> Any: - for key in keys: - value = data.get(key) - if value not in (None, ""): - return value - return "" - - -def as_string(value: Any) -> str: - return "" if value in (None, "") else str(value) - - -def authenticated_user(request): - user = getattr(request, "user", None) - return user if getattr(user, "is_authenticated", False) else None - - -def ensure_session_key(request) -> str: - if not getattr(request.session, "session_key", None): - request.session.create() - return request.session.session_key or "" - - -def get_active_draft(request) -> FilingDraft | None: - draft_id = request.session.get("filing_draft_id") - if draft_id: - try: - return FilingDraft.objects.get(pk=draft_id) - except FilingDraft.DoesNotExist: - request.session.pop("filing_draft_id", None) - request.session.modified = True - - user = authenticated_user(request) - if user: - draft = ( - FilingDraft.objects.filter(user=user, status__in=[FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR]) - .order_by("-updated_at") - .first() - ) - if draft: - request.session["filing_draft_id"] = draft.pk - request.session.modified = True - return draft +from efile.models import FilingDraft +from efile.workflow import WorkflowStepKey - session_key = getattr(request.session, "session_key", None) - if session_key: - draft = ( - FilingDraft.objects.filter(session_key=session_key, status__in=[FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR]) - .order_by("-updated_at") - .first() - ) - if draft: - request.session["filing_draft_id"] = draft.pk - request.session.modified = True - return draft +ACTIVE_DRAFT_STATUSES = (FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR) - return None +def active_drafts_for(user, *, jurisdiction: str | None = None) -> QuerySet[FilingDraft]: + """Return active drafts owned by ``user``, newest first.""" -@transaction.atomic -def create_draft(request, jurisdiction: str, *, current_step: str = FilingDraft.WorkflowStep.OPTIONS) -> FilingDraft: - session_key = ensure_session_key(request) - session_id = request.session.get("session_id") or str(uuid.uuid4()) - request.session["session_id"] = session_id - request.session["jurisdiction"] = jurisdiction - - draft = FilingDraft.objects.create( - user=authenticated_user(request), - session_key=session_key, - session_id=session_id, - jurisdiction=jurisdiction, - current_step=str(current_step), - ) - request.session["filing_draft_id"] = draft.pk - request.session.modified = True - return draft - - -@transaction.atomic -def ensure_draft(request, jurisdiction: str | None = None, *, current_step: str | None = None) -> FilingDraft: - draft = get_active_draft(request) - if draft is None: - return create_draft( - request, - jurisdiction or request.session.get("jurisdiction") or "", - current_step=current_step or FilingDraft.WorkflowStep.OPTIONS, - ) - - update_fields = [] - if jurisdiction and draft.jurisdiction != jurisdiction: - draft.jurisdiction = jurisdiction - update_fields.append("jurisdiction") - if current_step and draft.current_step != str(current_step): - draft.current_step = str(current_step) - update_fields.append("current_step") - if update_fields: - update_fields.append("updated_at") - draft.save(update_fields=update_fields) - - request.session["filing_draft_id"] = draft.pk - request.session.modified = True - return draft + drafts = FilingDraft.objects.filter(user=user, status__in=ACTIVE_DRAFT_STATUSES) + if jurisdiction is not None: + drafts = drafts.filter(jurisdiction=jurisdiction) + return drafts.order_by("-updated_at") -@transaction.atomic -def update_draft_from_case_data( - draft: FilingDraft, - case_data: Mapping[str, Any] | None, +def get_active_draft( *, - current_step: str | None = None, -) -> FilingDraft: - data = dict(case_data or {}) - update_fields = [] - - for draft_field, source_keys in CASE_FIELD_MAPPINGS.items(): - value = as_string(first_value(data, *source_keys)) - if getattr(draft, draft_field) != value: - setattr(draft, draft_field, value) - update_fields.append(draft_field) - - optional_services = data.get("optional_services") or [] - if draft.optional_services != optional_services: - draft.optional_services = optional_services - update_fields.append("optional_services") - - if draft.extra_case_data != data: - draft.extra_case_data = data - update_fields.append("extra_case_data") - - if current_step and draft.current_step != str(current_step): - draft.current_step = str(current_step) - update_fields.append("current_step") - - if update_fields: - update_fields.append("updated_at") - draft.save(update_fields=sorted(set(update_fields))) + user, + draft_id: int | str | None = None, + jurisdiction: str | None = None, +) -> FilingDraft | None: + """Get an owned active draft by ID, or the user's most recent draft.""" - sync_parties_from_case_data(draft, data) - return draft + drafts = active_drafts_for(user, jurisdiction=jurisdiction) + if draft_id is not None: + return drafts.filter(pk=draft_id).first() + return drafts.first() @transaction.atomic -def sync_documents_from_upload_data( - draft: FilingDraft, - upload_data: Mapping[str, Any] | None, +def create_draft( *, - current_step: str | None = None, + user, + jurisdiction: str, + current_step: WorkflowStepKey | str = WorkflowStepKey.OPTIONS, ) -> FilingDraft: - data = dict(upload_data or {}) - files = dict(data.get("files") or {}) - lead_document = files.get("lead") - if lead_document: - upsert_document(draft, FilingDocument.Role.LEAD, lead_document, data, sort_order=0) - - supporting_documents = files.get("supporting") or [] - supporting_configs = data.get("supporting_documents") or [] - kept_orders = [] - for index, document in enumerate(supporting_documents): - config = supporting_configs[index] if index < len(supporting_configs) else {} - upsert_document(draft, FilingDocument.Role.SUPPORTING, document, config, sort_order=index) - kept_orders.append(index) + """Create a durable draft owned by an authenticated user.""" - if kept_orders: - FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).exclude(sort_order__in=kept_orders).delete() - else: - FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING).delete() + if not getattr(user, "is_authenticated", False): + raise ValueError("A filing draft must have an authenticated owner") + if not jurisdiction: + raise ValueError("A filing draft must have a jurisdiction") - guesses = data.get("guesses") or {} - update_fields = [] - if guesses and draft.extracted_guesses != guesses: - draft.extracted_guesses = guesses - update_fields.append("extracted_guesses") - if current_step and draft.current_step != str(current_step): - draft.current_step = str(current_step) - update_fields.append("current_step") - if update_fields: - update_fields.append("updated_at") - draft.save(update_fields=update_fields) - - return draft - - -def sync_parties_from_case_data(draft: FilingDraft, case_data: Mapping[str, Any]) -> None: - petitioner = { - "party_type": as_string(first_value(case_data, "petitioner_party_type", "party_type", "determined_party_type")), - "first_name": as_string(first_value(case_data, "petitioner_first_name", "first_name")), - "last_name": as_string(first_value(case_data, "petitioner_last_name", "last_name")), - "email": as_string(first_value(case_data, "petitioner_email", "email")), - "phone": as_string(first_value(case_data, "petitioner_phone", "phone")), - "address_line_1": as_string(first_value(case_data, "petitioner_address", "address", "address_line_1")), - "metadata": {key: value for key, value in case_data.items() if key.startswith("petitioner_")}, - } - if any(petitioner.get(key) for key in ("party_type", "first_name", "last_name", "email")): - FilingParty.objects.update_or_create(draft=draft, role="petitioner", sort_order=0, defaults=petitioner) + return FilingDraft.objects.create( + user=user, + jurisdiction=jurisdiction, + current_step=str(current_step), + ) - name_sought = { - "party_type": as_string(first_value(case_data, "new_name_party_type")), - "first_name": as_string(first_value(case_data, "new_first_name")), - "middle_name": as_string(first_value(case_data, "new_middle_name")), - "last_name": as_string(first_value(case_data, "new_last_name")), - "suffix": as_string(first_value(case_data, "new_suffix")), - "metadata": {key: value for key, value in case_data.items() if key.startswith("new_") or key.startswith("reason_")}, - } - if any(name_sought.get(key) for key in ("party_type", "first_name", "last_name")): - FilingParty.objects.update_or_create(draft=draft, role="name_sought", sort_order=0, defaults=name_sought) +def set_current_step(draft: FilingDraft, current_step: WorkflowStepKey | str) -> FilingDraft: + """Advance or rewind a draft's current UI step when it changed.""" -def upsert_document( - draft: FilingDraft, - role: str, - document: Mapping[str, Any], - config: Mapping[str, Any], - *, - sort_order: int, -) -> FilingDocument: - document_data = dict(document or {}) - config_data = dict(config or {}) - defaults = { - "name": as_string(first_value(document_data, "name", "filename", "original_filename")), - "original_filename": as_string(first_value(document_data, "original_filename", "filename", "name")), - "size": positive_int(first_value(document_data, "size", "file_size")), - "content_type": as_string(first_value(document_data, "content_type", "mime_type", "type")), - "s3_key": as_string(first_value(document_data, "s3_key", "key")), - "public_url": as_string(first_value(document_data, "url", "s3_url", "file_url", "download_url")), - "filing_type_code": as_string(first_value(config_data, "filing_type", "lead_filing_type", "filing_type_code")), - "filing_type_name": as_string(first_value(config_data, "filing_type_name", "lead_filing_type_name")), - "document_type_code": as_string(first_value(config_data, "document_type", "lead_document_type", "document_type_code")), - "document_type_name": as_string(first_value(config_data, "document_type_name", "lead_document_type_name")), - "filing_component_code": as_string(first_value(config_data, "filing_component", "lead_filing_component", "filing_component_code")), - "filing_component_name": as_string(first_value(config_data, "filing_component_name", "lead_filing_component_name")), - "courtesy_copy_email": as_string(first_value(config_data, "cc_email", "lead_cc_email")), - "metadata": {"file": document_data, "config": config_data}, - } - document, _created = FilingDocument.objects.update_or_create( - draft=draft, - role=role, - sort_order=sort_order, - defaults=defaults, - ) - return document + step = str(current_step) + if draft.current_step != step: + draft.current_step = step + draft.save(update_fields=["current_step", "updated_at"]) + return draft def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: + """Return the stable, JSON-safe representation exposed to the UI.""" + if draft is None: return None return { @@ -289,6 +89,8 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: "case_category_name": draft.case_category_name, "case_type_code": draft.case_type_code, "case_type_name": draft.case_type_name, + "case_subtype_code": draft.case_subtype_code, + "case_subtype_name": draft.case_subtype_name, "filing_type_code": draft.filing_type_code, "filing_type_name": draft.filing_type_name, "document_type_code": draft.document_type_code, @@ -305,13 +107,3 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, "submitted_at": draft.submitted_at.isoformat() if draft.submitted_at else None, } - - -def positive_int(value: Any) -> int | None: - if value in (None, ""): - return None - try: - value = int(value) - except (TypeError, ValueError): - return None - return value if value >= 0 else None diff --git a/efile_app/efile/services/legacy_draft_bridge.py b/efile_app/efile/services/legacy_draft_bridge.py new file mode 100644 index 0000000..1ef1783 --- /dev/null +++ b/efile_app/efile/services/legacy_draft_bridge.py @@ -0,0 +1,263 @@ +"""Translate legacy session blobs into the durable filing draft aggregate. + +Only the session-backed endpoints should import this module. It can be removed +once those endpoints write typed draft fields directly. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from django.db import transaction + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.current_drafts import get_current_draft +from efile.workflow import WorkflowStepKey + +CASE_FIELD_MAPPINGS: dict[str, tuple[str, ...]] = { + "existing_case": ("existing_case",), + "court_code": ("court_code", "court"), + "court_name": ("court_name",), + "case_category_code": ("case_category_code", "case_category"), + "case_category_name": ("case_category_name",), + "case_type_code": ("case_type_code", "case_type"), + "case_type_name": ("case_type_name",), + "case_subtype_code": ("case_subtype_code", "case_subtype"), + "case_subtype_name": ("case_subtype_name",), + "filing_type_code": ("filing_type_code", "filing_type", "filing_type_id"), + "filing_type_name": ("filing_type_name",), + "document_type_code": ("document_type_code", "document_type"), + "document_type_name": ("document_type_name",), + "previous_case_id": ("previous_case_id", "case_tracking_id"), + "docket_number": ("docket_number", "case_docket_id"), + "selected_payment_account_id": ( + "selected_payment_account_id", + "selected_payment_account", + "payment_account_id", + ), + "selected_payment_account_name": ("selected_payment_account_name", "payment_account_name"), +} + +_MISSING = object() + + +def _first_present(data: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + if key in data: + return data[key] + return _MISSING + + +def _first_value(data: Mapping[str, Any], *keys: str) -> Any: + for key in keys: + value = data.get(key) + if value not in (None, ""): + return value + return "" + + +def _as_string(value: Any) -> str: + return "" if value in (None, "") else str(value) + + +@transaction.atomic +def update_draft_from_case_data( + draft: FilingDraft, + case_data: Mapping[str, Any] | None, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + """Mirror one complete legacy ``case_data`` blob into ``draft``.""" + + data = dict(case_data or {}) + update_fields = [] + + for draft_field, source_keys in CASE_FIELD_MAPPINGS.items(): + source_value = _first_present(data, *source_keys) + if source_value is _MISSING: + continue + value = _as_string(source_value) + if getattr(draft, draft_field) != value: + setattr(draft, draft_field, value) + update_fields.append(draft_field) + + if "optional_services" in data: + optional_services = data.get("optional_services") or [] + if draft.optional_services != optional_services: + draft.optional_services = optional_services + update_fields.append("optional_services") + + if draft.extra_case_data != data: + draft.extra_case_data = data + update_fields.append("extra_case_data") + + if current_step is not None and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + + if update_fields: + draft.save(update_fields=sorted({*update_fields, "updated_at"})) + + _sync_parties_from_case_data(draft, data) + return draft + + +@transaction.atomic +def sync_documents_from_upload_data( + draft: FilingDraft, + upload_data: Mapping[str, Any] | None, + *, + current_step: WorkflowStepKey | str | None = None, +) -> FilingDraft: + """Mirror one complete legacy ``upload_data`` blob into ``draft``.""" + + data = dict(upload_data or {}) + files = dict(data.get("files") or {}) + lead_document = files.get("lead") + if lead_document: + _upsert_document(draft, FilingDocument.Role.LEAD, lead_document, data, sort_order=0) + else: + FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.LEAD).delete() + + supporting_documents = files.get("supporting") or [] + supporting_configs = data.get("supporting_documents") or [] + kept_orders = [] + for index, document in enumerate(supporting_documents): + config = supporting_configs[index] if index < len(supporting_configs) else {} + _upsert_document(draft, FilingDocument.Role.SUPPORTING, document, config, sort_order=index) + kept_orders.append(index) + + supporting = FilingDocument.objects.filter(draft=draft, role=FilingDocument.Role.SUPPORTING) + if kept_orders: + supporting.exclude(sort_order__in=kept_orders).delete() + else: + supporting.delete() + + guesses = data.get("guesses") or {} + update_fields = [] + if draft.extracted_guesses != guesses: + draft.extracted_guesses = guesses + update_fields.append("extracted_guesses") + if current_step is not None and draft.current_step != str(current_step): + draft.current_step = str(current_step) + update_fields.append("current_step") + draft.save(update_fields=[*update_fields, "updated_at"]) + + return draft + + +def sync_current_draft_case_data(request, case_data: Mapping[str, Any]) -> FilingDraft | None: + """Compatibility hook for a session endpoint that just saved case data.""" + + jurisdiction = _as_string( + _first_value(case_data, "jurisdiction_id", "jurisdiction") or request.session.get("jurisdiction") + ) + draft = get_current_draft(request, jurisdiction=jurisdiction or None, resume_latest=False) + if draft is not None: + update_draft_from_case_data(draft, case_data) + return draft + + +def sync_current_draft_upload_data(request, upload_data: Mapping[str, Any]) -> FilingDraft | None: + """Compatibility hook for a session endpoint that just saved upload data.""" + + jurisdiction = _as_string(request.session.get("jurisdiction")) + draft = get_current_draft(request, jurisdiction=jurisdiction or None, resume_latest=False) + if draft is not None: + sync_documents_from_upload_data(draft, upload_data) + return draft + + +def _sync_parties_from_case_data(draft: FilingDraft, case_data: Mapping[str, Any]) -> None: + petitioner = { + "party_type": _as_string( + _first_value(case_data, "petitioner_party_type", "party_type", "determined_party_type") + ), + "first_name": _as_string(_first_value(case_data, "petitioner_first_name", "first_name")), + "middle_name": _as_string(_first_value(case_data, "petitioner_middle_name", "middle_name")), + "last_name": _as_string(_first_value(case_data, "petitioner_last_name", "last_name")), + "suffix": _as_string(_first_value(case_data, "petitioner_suffix", "suffix")), + "email": _as_string(_first_value(case_data, "petitioner_email", "email")), + "phone": _as_string(_first_value(case_data, "petitioner_phone", "phone")), + "address_line_1": _as_string(_first_value(case_data, "petitioner_address", "address", "address_line_1")), + "address_line_2": _as_string(_first_value(case_data, "petitioner_address_line_2", "address_line2")), + "city": _as_string(_first_value(case_data, "petitioner_city", "city")), + "state": _as_string(_first_value(case_data, "petitioner_state", "state")), + "zip_code": _as_string(_first_value(case_data, "petitioner_zip", "zip", "zip_code")), + "metadata": {key: value for key, value in case_data.items() if key.startswith("petitioner_")}, + } + _upsert_or_delete_party(draft, "petitioner", petitioner) + + name_sought = { + "party_type": _as_string(_first_value(case_data, "new_name_party_type")), + "first_name": _as_string(_first_value(case_data, "new_first_name")), + "middle_name": _as_string(_first_value(case_data, "new_middle_name")), + "last_name": _as_string(_first_value(case_data, "new_last_name")), + "suffix": _as_string(_first_value(case_data, "new_suffix")), + "metadata": { + key: value for key, value in case_data.items() if key.startswith("new_") or key.startswith("reason_") + }, + } + _upsert_or_delete_party(draft, "name_sought", name_sought) + + +def _upsert_or_delete_party(draft: FilingDraft, role: str, values: dict[str, Any]) -> None: + meaningful_fields = set(values) - {"metadata"} + if any(values[field] for field in meaningful_fields): + FilingParty.objects.update_or_create(draft=draft, role=role, sort_order=0, defaults=values) + else: + FilingParty.objects.filter(draft=draft, role=role, sort_order=0).delete() + + +def _upsert_document( + draft: FilingDraft, + role: str, + document: Mapping[str, Any], + config: Mapping[str, Any], + *, + sort_order: int, +) -> FilingDocument: + document_data = dict(document or {}) + config_data = dict(config or {}) + defaults = { + "name": _as_string(_first_value(document_data, "name", "filename", "original_filename")), + "original_filename": _as_string(_first_value(document_data, "original_filename", "filename", "name")), + "size": _positive_int(_first_value(document_data, "size", "file_size")), + "content_type": _as_string(_first_value(document_data, "content_type", "mime_type", "type")), + "s3_key": _as_string(_first_value(document_data, "s3_key", "key")), + "public_url": _as_string(_first_value(document_data, "url", "s3_url", "file_url", "download_url")), + "filing_type_code": _as_string( + _first_value(config_data, "filing_type", "lead_filing_type", "filing_type_code") + ), + "filing_type_name": _as_string(_first_value(config_data, "filing_type_name", "lead_filing_type_name")), + "document_type_code": _as_string( + _first_value(config_data, "document_type", "lead_document_type", "document_type_code") + ), + "document_type_name": _as_string(_first_value(config_data, "document_type_name", "lead_document_type_name")), + "filing_component_code": _as_string( + _first_value(config_data, "filing_component", "lead_filing_component", "filing_component_code") + ), + "filing_component_name": _as_string( + _first_value(config_data, "filing_component_name", "lead_filing_component_name") + ), + "courtesy_copy_email": _as_string(_first_value(config_data, "cc_email", "lead_cc_email")), + "metadata": {"file": document_data, "config": config_data}, + } + document, _created = FilingDocument.objects.update_or_create( + draft=draft, + role=role, + sort_order=sort_order, + defaults=defaults, + ) + return document + + +def _positive_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + value = int(value) + except (TypeError, ValueError): + return None + return value if value >= 0 else None diff --git a/efile_app/efile/tests/test_durable_drafts.py b/efile_app/efile/tests/test_durable_drafts.py index e97edb9..50ed8c6 100644 --- a/efile_app/efile/tests/test_durable_drafts.py +++ b/efile_app/efile/tests/test_durable_drafts.py @@ -4,12 +4,16 @@ from django.urls import reverse from efile.models import FilingDocument, FilingDraft, FilingParty -from efile.services.drafts import draft_snapshot, sync_documents_from_upload_data, update_draft_from_case_data +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY, get_current_draft +from efile.services.drafts import draft_snapshot +from efile.services.legacy_draft_bridge import sync_documents_from_upload_data, update_draft_from_case_data +from efile.workflow import WorkflowStepKey, get_workflow_step_choices @pytest.mark.django_db -def test_update_draft_from_case_data_normalizes_known_fields(): - draft = FilingDraft.objects.create(jurisdiction="illinois") +def test_update_draft_from_case_data_normalizes_known_fields(django_user_model): + user = django_user_model.objects.create_user(username="draft-owner", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") case_data = { "court": "cook:cd", "court_name": "Cook County Circuit Court", @@ -31,10 +35,10 @@ def test_update_draft_from_case_data_normalizes_known_fields(): "new_last_name": "Lovelace", } - update_draft_from_case_data(draft, case_data, current_step=FilingDraft.WorkflowStep.CASE_INFORMATION) + update_draft_from_case_data(draft, case_data, current_step=WorkflowStepKey.CASE_INFORMATION) draft.refresh_from_db() - assert draft.current_step == FilingDraft.WorkflowStep.CASE_INFORMATION + assert draft.current_step == WorkflowStepKey.CASE_INFORMATION assert draft.court_code == "cook:cd" assert draft.case_category_code == "MR" assert draft.case_type_code == "Name Change" @@ -54,8 +58,9 @@ def test_update_draft_from_case_data_normalizes_known_fields(): @pytest.mark.django_db -def test_sync_documents_from_upload_data_creates_lead_and_supporting_documents(): - draft = FilingDraft.objects.create(jurisdiction="illinois") +def test_sync_documents_from_upload_data_creates_lead_and_supporting_documents(django_user_model): + user = django_user_model.objects.create_user(username="document-owner", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") upload_data = { "files": { "lead": { @@ -88,10 +93,10 @@ def test_sync_documents_from_upload_data_creates_lead_and_supporting_documents() ], } - sync_documents_from_upload_data(draft, upload_data, current_step=FilingDraft.WorkflowStep.DOCUMENTS) + sync_documents_from_upload_data(draft, upload_data, current_step=WorkflowStepKey.DOCUMENTS) draft.refresh_from_db() - assert draft.current_step == FilingDraft.WorkflowStep.DOCUMENTS + assert draft.current_step == WorkflowStepKey.DOCUMENTS assert draft.extracted_guesses == {"court": "Cook County"} lead = FilingDocument.objects.get(draft=draft, role=FilingDocument.Role.LEAD) @@ -106,11 +111,13 @@ def test_sync_documents_from_upload_data_creates_lead_and_supporting_documents() @pytest.mark.django_db -def test_draft_snapshot_is_json_serializable(): - draft = FilingDraft.objects.create(jurisdiction="illinois", court_code="cook:cd") +def test_draft_snapshot_is_json_serializable(django_user_model): + user = django_user_model.objects.create_user(username="snapshot-owner", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois", court_code="cook:cd") snapshot = draft_snapshot(draft) + assert snapshot is not None assert snapshot["id"] == draft.pk assert snapshot["court_code"] == "cook:cd" json.dumps(snapshot) @@ -138,5 +145,116 @@ def test_create_draft_view_creates_durable_draft(client, django_user_model): draft = FilingDraft.objects.get(user=user) assert draft.jurisdiction == "illinois" - assert draft.current_step == FilingDraft.WorkflowStep.UPLOAD_FIRST + assert draft.current_step == WorkflowStepKey.UPLOAD_FIRST assert payload["data"]["filing_draft"]["id"] == draft.pk + + +@pytest.mark.django_db +def test_current_draft_enforces_owner(client, django_user_model): + illinois_user = django_user_model.objects.create_user(username="illinois-user", tyler_jurisdiction="illinois") + other_user = django_user_model.objects.create_user(username="other-user", tyler_jurisdiction="massachusetts") + other_draft = FilingDraft.objects.create(user=other_user, jurisdiction="illinois") + expected_draft = FilingDraft.objects.create(user=illinois_user, jurisdiction="illinois") + + client.force_login(illinois_user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = other_draft.pk + session.save() + + response = client.get(reverse("get_current_draft")) + + assert response.status_code == 200 + assert response.json()["data"]["filing_draft"]["id"] == expected_draft.pk + + +@pytest.mark.django_db +def test_current_draft_does_not_cross_jurisdictions(client, django_user_model): + user = django_user_model.objects.create_user(username="multi-state-user", tyler_jurisdiction="illinois") + illinois_draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") + massachusetts_draft = FilingDraft.objects.create(user=user, jurisdiction="massachusetts") + client.force_login(user) + session = client.session + session[CURRENT_DRAFT_SESSION_KEY] = massachusetts_draft.pk + session.save() + + request = type("Request", (), {"user": user, "session": client.session})() + current = get_current_draft(request, jurisdiction="illinois") + + assert current == illinois_draft + + +@pytest.mark.django_db +def test_legacy_case_endpoint_mirrors_into_current_draft(client, django_user_model): + user = django_user_model.objects.create_user(username="bridge-user", tyler_jurisdiction="illinois") + client.force_login(user) + client.post( + reverse("create_draft", kwargs={"jurisdiction": "illinois"}), + data={}, + content_type="application/json", + ) + + response = client.post( + reverse("save_case_data_api"), + data={ + "jurisdiction": "illinois", + "data": { + "existing_case": "no", + "court": "cook:cd", + "case_type": "Name Change", + "petitioner_first_name": "Ada", + "petitioner_last_name": "Lovelace", + }, + }, + content_type="application/json", + ) + + assert response.status_code == 200 + draft = FilingDraft.objects.get(user=user) + assert draft.court_code == "cook:cd" + assert draft.case_type_code == "Name Change" + assert draft.parties.get(role="petitioner").first_name == "Ada" + + +@pytest.mark.django_db +def test_model_step_choices_follow_workflow_registry(): + current_step = FilingDraft._meta.get_field("current_step") + + assert tuple(current_step.choices) == get_workflow_step_choices() + + +@pytest.mark.django_db +def test_legacy_partial_case_update_does_not_clear_omitted_fields(django_user_model): + user = django_user_model.objects.create_user(username="partial-update-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + court_code="old-code", + court_name="Court name to preserve", + ) + + update_draft_from_case_data(draft, {"court": "new-code"}) + draft.refresh_from_db() + + assert draft.court_code == "new-code" + assert draft.court_name == "Court name to preserve" + + +@pytest.mark.django_db +def test_legacy_upload_sync_removes_state_missing_from_complete_blob(django_user_model): + user = django_user_model.objects.create_user(username="upload-replace-user", tyler_jurisdiction="illinois") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + extracted_guesses={"court": "Old guess"}, + ) + FilingDocument.objects.create( + draft=draft, + role=FilingDocument.Role.LEAD, + name="old.pdf", + ) + + sync_documents_from_upload_data(draft, {"files": {}, "guesses": {}}) + draft.refresh_from_db() + + assert draft.extracted_guesses == {} + assert not draft.documents.exists() diff --git a/efile_app/efile/views/api_views.py b/efile_app/efile/views/api_views.py index 800ef2e..905aabf 100644 --- a/efile_app/efile/views/api_views.py +++ b/efile_app/efile/views/api_views.py @@ -8,7 +8,9 @@ from django.views import View from django.views.decorators.csrf import ensure_csrf_cookie -from efile.services.drafts import draft_snapshot, get_active_draft, update_draft_from_case_data +from efile.services.current_drafts import get_current_draft +from efile.services.drafts import draft_snapshot +from efile.services.legacy_draft_bridge import sync_current_draft_case_data logger = logging.getLogger(__name__) @@ -25,7 +27,7 @@ def get(self, request): if value: data[param_to_check] = value - draft = get_active_draft(request) + draft = get_current_draft(request, resume_latest=False) if draft: data["filing_draft"] = draft_snapshot(draft) @@ -81,9 +83,7 @@ def post(self, request): request.session["case_data"] = case_data request.session.modified = True - draft = get_active_draft(request) - if draft: - update_draft_from_case_data(draft, case_data) + sync_current_draft_case_data(request, case_data) logger.debug(f"Saved case data to session: {case_data}") diff --git a/efile_app/efile/views/draft_views.py b/efile_app/efile/views/draft_views.py index c8ff550..391475a 100644 --- a/efile_app/efile/views/draft_views.py +++ b/efile_app/efile/views/draft_views.py @@ -4,7 +4,8 @@ from django.http import JsonResponse from django.views.decorators.http import require_http_methods -from efile.services.drafts import create_draft, draft_snapshot, get_active_draft +from efile.services.current_drafts import create_current_draft, get_current_draft +from efile.services.drafts import draft_snapshot from efile.utils.django_helpers import flush_cache_stay_logged_in from efile.workflow import WorkflowStepKey, get_step_url @@ -19,12 +20,14 @@ def create_draft_view(request, jurisdiction): return JsonResponse({"success": False, "error": "Authentication required"}, status=401) try: - json.loads(request.body or "{}") + payload = json.loads(request.body or "{}") except json.JSONDecodeError: return JsonResponse({"success": False, "error": "Invalid JSON data"}, status=400) + if not isinstance(payload, dict): + return JsonResponse({"success": False, "error": "JSON body must be an object"}, status=400) flush_cache_stay_logged_in(request.session) - draft = create_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) + draft = create_current_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) logger.info("Created durable draft id=%s jurisdiction=%s", draft.pk, jurisdiction) return JsonResponse( @@ -40,5 +43,8 @@ def create_draft_view(request, jurisdiction): def get_current_draft_view(request): """Return the durable draft attached to this session/user.""" - draft = get_active_draft(request) + if not request.user.is_authenticated: + return JsonResponse({"success": False, "error": "Authentication required"}, status=401) + + draft = get_current_draft(request) return JsonResponse({"success": True, "data": {"filing_draft": draft_snapshot(draft)}}) diff --git a/efile_app/efile/views/options.py b/efile_app/efile/views/options.py index 5c7f9c0..c85110c 100644 --- a/efile_app/efile/views/options.py +++ b/efile_app/efile/views/options.py @@ -2,7 +2,8 @@ from django.views.decorators.csrf import ensure_csrf_cookie from efile.api.suffolk_api_views import get_tyler_token -from efile.services.drafts import draft_snapshot, get_active_draft +from efile.services.current_drafts import get_current_draft +from efile.services.drafts import draft_snapshot from ..utils.case_data_utils import get_case_data from ..workflow import WorkflowStepKey, get_workflow_context @@ -14,7 +15,7 @@ def efile_options(request, jurisdiction): # Get case data from session if request.user.is_authenticated: case_data = get_case_data(request) - active_draft = get_active_draft(request) + active_draft = get_current_draft(request, jurisdiction=jurisdiction) else: case_data = {} active_draft = None diff --git a/efile_app/efile/views/session_api.py b/efile_app/efile/views/session_api.py index 6bde809..b8bbeb7 100644 --- a/efile_app/efile/views/session_api.py +++ b/efile_app/efile/views/session_api.py @@ -8,6 +8,11 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods +from efile.services.legacy_draft_bridge import ( + sync_current_draft_case_data, + sync_current_draft_upload_data, +) + from ..utils.case_data_utils import clear_case_data, clear_upload_data, get_upload_data from ..utils.llms import LlmError, extract_fields_from_file from ..utils.proxy_connection import get_party_type_code_from_api @@ -219,6 +224,7 @@ def save_form_data_to_session(request): # Persist to session request.session["case_data"] = case_data request.session.modified = True + sync_current_draft_case_data(request, case_data) return JsonResponse({"success": True, "message": "Case data saved to session"}) @@ -271,6 +277,7 @@ def save_upload_first_data(request): # Save to session request.session["upload_data"] = upload_data request.session.modified = True + sync_current_draft_upload_data(request, upload_data) logger.info("Successfully saved upload data to session") return JsonResponse({"success": True, "message": "Upload data saved to session"}) @@ -309,6 +316,7 @@ def save_upload_data_to_session(request): # Save to session request.session["upload_data"] = upload_data request.session.modified = True + sync_current_draft_upload_data(request, upload_data) logger.info("Successfully saved upload data to session") return JsonResponse({"success": True, "message": "Upload data saved to session"}) @@ -580,6 +588,7 @@ def api_save_case_data(request): request.session["case_data"] = case_data request.session.modified = True + sync_current_draft_case_data(request, case_data) return JsonResponse( {"success": True, "data": {"existing_case": existing_case, "saved_fields": list(form_data.keys())}} @@ -667,6 +676,7 @@ def fetch_and_save_party_type(request): case_data["petitioner_party_type"] = party_type_code request.session["case_data"] = case_data request.session.modified = True + sync_current_draft_case_data(request, case_data) logger.debug(f"Saved party type to session: {party_type_code}") @@ -700,6 +710,7 @@ def save_party_type_to_session(request): case_data["available_party_types"] = party_types_available request.session["case_data"] = case_data request.session.modified = True + sync_current_draft_case_data(request, case_data) logger.debug(f"Saved party type to session: {party_type}") diff --git a/efile_app/efile/views/upload_first.py b/efile_app/efile/views/upload_first.py index fa538ad..39008d9 100644 --- a/efile_app/efile/views/upload_first.py +++ b/efile_app/efile/views/upload_first.py @@ -4,7 +4,8 @@ from django.shortcuts import redirect, render from efile.api.suffolk_api_views import get_tyler_token -from efile.services.drafts import draft_snapshot, ensure_draft +from efile.services.current_drafts import ensure_current_draft +from efile.services.drafts import draft_snapshot from ..utils.case_data_utils import ( get_case_classification, @@ -39,7 +40,7 @@ def efile_upload_first(request, jurisdiction): request.session["jurisdiction"] = jurisdiction request.session.modified = True - filing_draft = ensure_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) + filing_draft = ensure_current_draft(request, jurisdiction, current_step=WorkflowStepKey.UPLOAD_FIRST) # Could visit here from a back button press, so use upload data if any upload_data = get_upload_data(request) diff --git a/efile_app/efile/workflow.py b/efile_app/efile/workflow.py index d2445d2..9213899 100644 --- a/efile_app/efile/workflow.py +++ b/efile_app/efile/workflow.py @@ -60,6 +60,12 @@ def get_workflow_steps() -> tuple[WorkflowStep, ...]: return FILING_WORKFLOW +def get_workflow_step_choices() -> tuple[tuple[str, str], ...]: + """Return Django model choices derived from the workflow registry.""" + + return tuple((step.key.value, step.label) for step in FILING_WORKFLOW) + + def get_step(step_key: WorkflowStepKey | str) -> WorkflowStep: try: return next(step for step in FILING_WORKFLOW if step.key == step_key) diff --git a/fly.toml b/fly.toml index 2ac699c..b81c826 100644 --- a/fly.toml +++ b/fly.toml @@ -13,7 +13,7 @@ primary_region = 'lax' [deploy] # Run database migrations before each release - release_command = "uv run python manage.py migrate --noinput" + release_command = "uv run python manage.py migrate --noinput --fake-initial" [http_service] internal_port = 8000 From c11213749ec4dae89be5e9950a8313eb4a7f22b2 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 29 Jun 2026 19:13:40 -0400 Subject: [PATCH 18/18] Type fixes --- efile_app/efile/admin.py | 4 +++- efile_app/efile/services/drafts.py | 6 +++--- efile_app/efile/tests/test_workflow.py | 2 ++ efile_app/efile/views/options.py | 5 +++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/efile_app/efile/admin.py b/efile_app/efile/admin.py index ee98bef..8434f16 100644 --- a/efile_app/efile/admin.py +++ b/efile_app/efile/admin.py @@ -1,3 +1,5 @@ +from typing import Any, cast + from django.contrib import admin from django.contrib.auth.admin import UserAdmin @@ -28,7 +30,7 @@ class FilingPartyInline(admin.TabularInline): @admin.register(UserProfile) class UserProfileAdmin(UserAdmin): - fieldsets = UserAdmin.fieldsets + ( + fieldsets = cast(tuple[Any, ...], UserAdmin.fieldsets) + ( ( "eFile profile", { diff --git a/efile_app/efile/services/drafts.py b/efile_app/efile/services/drafts.py index 9bb1344..9632a6c 100644 --- a/efile_app/efile/services/drafts.py +++ b/efile_app/efile/services/drafts.py @@ -12,7 +12,7 @@ from django.db import transaction from django.db.models import QuerySet -from efile.models import FilingDraft +from efile.models import FilingDocument, FilingDraft, FilingParty from efile.workflow import WorkflowStepKey ACTIVE_DRAFT_STATUSES = (FilingDraft.Status.DRAFT, FilingDraft.Status.ERROR) @@ -101,8 +101,8 @@ def draft_snapshot(draft: FilingDraft | None) -> dict[str, Any] | None: "selected_payment_account_name": draft.selected_payment_account_name, "optional_services": draft.optional_services, "extracted_guesses": draft.extracted_guesses, - "document_count": draft.documents.count(), - "party_count": draft.parties.count(), + "document_count": FilingDocument.objects.filter(draft=draft).count(), + "party_count": FilingParty.objects.filter(draft=draft).count(), "created_at": draft.created_at.isoformat() if draft.created_at else None, "updated_at": draft.updated_at.isoformat() if draft.updated_at else None, "submitted_at": draft.submitted_at.isoformat() if draft.submitted_at else None, diff --git a/efile_app/efile/tests/test_workflow.py b/efile_app/efile/tests/test_workflow.py index 98437bd..51b7cf6 100644 --- a/efile_app/efile/tests/test_workflow.py +++ b/efile_app/efile/tests/test_workflow.py @@ -59,12 +59,14 @@ def test_get_previous_step_returns_none_for_first_step(): def test_get_previous_step_returns_prior_step(): previous_step = get_previous_step(WorkflowStepKey.CASE_INFORMATION) + assert previous_step is not None assert previous_step.key == WorkflowStepKey.UPLOAD_FIRST def test_get_next_step_returns_following_step(): next_step = get_next_step(WorkflowStepKey.CASE_INFORMATION) + assert next_step is not None assert next_step.key == WorkflowStepKey.DOCUMENTS diff --git a/efile_app/efile/views/options.py b/efile_app/efile/views/options.py index c85110c..b376cef 100644 --- a/efile_app/efile/views/options.py +++ b/efile_app/efile/views/options.py @@ -1,5 +1,5 @@ +from django.middleware.csrf import get_token from django.shortcuts import render -from django.views.decorators.csrf import ensure_csrf_cookie from efile.api.suffolk_api_views import get_tyler_token from efile.services.current_drafts import get_current_draft @@ -9,9 +9,10 @@ from ..workflow import WorkflowStepKey, get_workflow_context -@ensure_csrf_cookie def efile_options(request, jurisdiction): """Options view that displays saved case data and provides next steps.""" + get_token(request) + # Get case data from session if request.user.is_authenticated: case_data = get_case_data(request)