Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/filing-draft-data-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 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.

## Service boundaries

The durable draft code is split into three layers:

- `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 creates a `FilingDraft` through `POST /jurisdiction/<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. 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_draft_bridge.py`, arbitrary session-merge APIs, and browser storage persistence.
88 changes: 88 additions & 0 deletions efile_app/efile/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from typing import Any, cast

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 = cast(tuple[Any, ...], 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",
"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")
111 changes: 111 additions & 0 deletions efile_app/efile/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Generated by Django 5.2.5 on 2026-06-29

import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):
"""Baseline the pre-existing custom user table.

Existing environments should apply this with ``migrate --fake-initial``;
fresh databases create the table normally.
"""

initial = True

dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]

operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("password", models.CharField(max_length=128, verbose_name="password")),
("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={"unique": "A user with that username already exists."},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
verbose_name="username",
),
),
("first_name", models.CharField(blank=True, max_length=150, verbose_name="first name")),
("last_name", models.CharField(blank=True, max_length=150, verbose_name="last name")),
("email", models.EmailField(blank=True, max_length=254, verbose_name="email address")),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"Designates whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
verbose_name="active",
),
),
("date_joined", models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined")),
("tyler_jurisdiction", models.CharField(max_length=20)),
("tyler_user_id", models.CharField(blank=True, max_length=100, null=True)),
("email_updates", models.BooleanField(default=False)),
("text_updates", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text=(
"The groups this user belongs to. A user will get all permissions granted to each of "
"their groups."
),
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "User Profile",
"verbose_name_plural": "User Profiles",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
Loading