Skip to content
Merged
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
28 changes: 28 additions & 0 deletions efile_app/efile/api/config_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from django.views.decorators.http import require_http_methods

from ..services.document_checklists import resolve_filer_roles
from ..utils.config_loader import config_loader
from .base import APIResponseMixin

Expand Down Expand Up @@ -70,6 +71,33 @@ def get_form_config(request):
except Exception as e:
return ConfigAPIViews.error_response(f"Error: {str(e)}")

@staticmethod
@require_http_methods(["GET"])
def get_filer_roles(request):
"""Get the sides a filing in this case can come from.

Empty for most case types. Where it is not empty -- an eviction, where
the landlord and the tenant file different documents under one case
type -- the confirm-filing screen asks which side the filer is on, so
it must ask while the case type is still being chosen, before anything
is saved. Everything here is looked up by name from partner
configuration; no court codes are involved.
"""

jurisdiction = request.GET.get("jurisdiction") or request.session.get("jurisdiction")
if not jurisdiction:
return ConfigAPIViews.error_response("Missing required parameter: jurisdiction")

roles = resolve_filer_roles(
jurisdiction=jurisdiction,
court_code=request.GET.get("court", ""),
case_category_name=request.GET.get("case_category_name", ""),
case_type_name=request.GET.get("case_type_name", ""),
lead_filing_type_name=request.GET.get("filing_type_name", ""),
)
return ConfigAPIViews.success_response(roles)


# Individual view functions for URL mapping
get_form_config = ConfigAPIViews.get_form_config
get_filer_roles = ConfigAPIViews.get_filer_roles
87 changes: 50 additions & 37 deletions efile_app/efile/api/filing_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,55 @@ def get_tyler_token(request, jurisdiction=None):
return tyler_token


def list_filing_data(request, jurisdiction, *, start_date=None):
"""Return the current Tyler account's filings in its normalized API shape.

Both the filing-history endpoint and the plan case-link action need this
account-scoped list. Keeping the proxy call here means the latter cannot
accidentally validate a browser-supplied case ID instead.
"""

api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/0/filings"
headers = get_headers()
tyler_token = get_tyler_token(request, jurisdiction)
if tyler_token:
headers[f"tyler-token-{jurisdiction}"] = tyler_token
else:
logger.info("No Tyler token found for state '%s' in filing-history request", jurisdiction)

response = requests.get(
api_url,
params={"start_date": start_date or None, "before_date": None},
headers=headers,
timeout=30,
)
logger.debug(
"Get filings response: status=%s content_type=%s",
response.status_code,
response.headers.get("Content-Type"),
)
response.raise_for_status()
return [FilingAPIViews.convert_filing_data(filing) for filing in response.json()]


def accepted_case_for_user(request, jurisdiction, case_tracking_id, *, start_date=None):
"""Return an accepted case the current account has filed into, if any."""

wanted = str(case_tracking_id or "")
if not wanted:
return None
return next(
(
filing
for filing in list_filing_data(request, jurisdiction, start_date=start_date)
if str(filing.get("filing_status", "")).lower() == "accepted"
and str(filing.get("case_tracking_id", "")) == wanted
and filing.get("case_number")
),
None,
)


class FilingAPIViews(APIResponseMixin):
"""API views for filing operations"""

Expand All @@ -59,43 +108,7 @@ def get_filings(request):
if not jurisdiction:
return JsonResponse({"success": False, "error": "Jurisdiction parameter is required"}, status=400)

court = "0" # hardcoded to get filings from all courts
before_date = None # defaults to now

api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/{court}/filings"

# Add query parameter for docket number
params = {
"start_date": start_date if start_date else None,
"before_date": before_date if before_date else None,
}

logger.info(f"Looking up filings in all courts at {api_url}")

# Get authentication credentials dynamically
tyler_token = get_tyler_token(request, jurisdiction)

headers = get_headers()
# Add Tyler token if available
if tyler_token:
headers[f"tyler-token-{jurisdiction}"] = tyler_token
else:
# Log that no token was found for debugging
logger.info(
"No Tyler token found for state '%s' in Suffolk case lookup request",
jurisdiction,
)

# Make the API request - using GET with query parameters
response = requests.get(api_url, params=params, headers=headers, timeout=30)
logger.debug(
"Get filings response: status=%s content_type=%s",
response.status_code,
response.headers.get("Content-Type"),
)
response.raise_for_status()
api_data = [FilingAPIViews.convert_filing_data(filing) for filing in response.json()]
return FilingAPIViews.success_response(api_data)
return FilingAPIViews.success_response(list_filing_data(request, jurisdiction, start_date=start_date))
except requests.RequestException as e:
logger.exception("Network error calling Suffolk API")
return FilingAPIViews.error_response(f"Network error: {str(e)}", status_code=500)
Expand Down
3 changes: 2 additions & 1 deletion efile_app/efile/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
user_profile,
)
from .case_type_config import get_case_type_config
from .config_views import get_form_config
from .config_views import get_filer_roles, get_form_config
from .dropdown_views import (
get_case_categories,
get_case_types,
Expand Down Expand Up @@ -53,6 +53,7 @@
# Form configuration endpoints
path("form-config/", get_form_config, name="form_config"),
path("case-type-config/", get_case_type_config, name="case_type_config"),
path("filer-roles/", get_filer_roles, name="filer_roles"),
# Suffolk API endpoints
path("suffolk/lookup-case/", lookup_case, name="lookup_case"),
# Authentication API endpoints
Expand Down
82 changes: 82 additions & 0 deletions efile_app/efile/migrations/0012_filing_plans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Generated by Django 5.2.5

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("efile", "0011_merge_jurisdiction_accounts_and_workflow"),
]

operations = [
migrations.CreateModel(
name="FilingPlan",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
("jurisdiction", models.CharField(db_index=True, max_length=40)),
("court_code", models.CharField(blank=True, max_length=100)),
("court_name", models.CharField(blank=True, max_length=255)),
("case_category_name", models.CharField(blank=True, max_length=255)),
("case_type_name", models.CharField(blank=True, max_length=255)),
("lead_filing_type_name", models.CharField(blank=True, max_length=255)),
("filer_role", models.CharField(blank=True, max_length=60)),
("case_tracking_id", models.CharField(blank=True, max_length=255)),
("docket_number", models.CharField(blank=True, max_length=255)),
("case_title", models.CharField(blank=True, max_length=500)),
("checklist", models.JSONField(blank=True, default=dict)),
("guidance", models.JSONField(blank=True, default=dict)),
("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_plans",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-updated_at"],
},
),
migrations.AddField(
model_name="filingdraft",
name="plan",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="filing_drafts",
to="efile.filingplan",
),
),
migrations.AddField(
model_name="filingdraft",
name="filer_role",
field=models.CharField(blank=True, max_length=60),
),
migrations.AddField(
model_name="filingdocument",
name="checklist_item_id",
field=models.CharField(blank=True, max_length=100),
),
migrations.AddIndex(
model_name="filingplan",
index=models.Index(
fields=["user", "jurisdiction"], name="plan_user_jurisdiction_idx"
),
),
]
91 changes: 91 additions & 0 deletions efile_app/efile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,78 @@ def account_email(self):
return self.tyler_username or self.email or self.username


class FilingPlan(models.Model):
"""A filer's long-lived matter: the documents they are gathering for it.

A plan outlives any one envelope. It stores what the filer's case *is* in
semantic terms -- the court, case category, case type, and lead filing type
by name -- and never the court's numeric codes for them. Those codes belong
to a filing: they differ per court and change without notice, so a later
filing resolves the stored names against the live code lists instead of
trusting a code saved months ago.

``checklist`` is a snapshot of the configured guidance, taken when the plan
is created, plus the filer's own progress. Snapshotting means a partner
editing the YAML later does not silently rewrite a checklist someone is
already working through.
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="filing_plans",
)
title = models.CharField(max_length=255)
jurisdiction = models.CharField(max_length=40, db_index=True)

court_code = models.CharField(max_length=100, blank=True)
court_name = models.CharField(max_length=255, blank=True)
case_category_name = models.CharField(max_length=255, blank=True)
case_type_name = models.CharField(max_length=255, blank=True)
lead_filing_type_name = models.CharField(max_length=255, blank=True)

# Which side of the case the filer is on, as one of the role IDs the
# partner configured for this case type ("landlord", "tenant"). It decides
# which documents the checklist lists and how they are worded, so it is
# part of what the matter *is*, not of any one envelope.
filer_role = models.CharField(max_length=60, blank=True)

# The court case this matter has become, once one exists: Tyler's case
# tracking ID plus the docket number and title a person recognizes. Unlike
# the code fields above, a tracking ID is a permanent identifier for one
# case rather than a lookup key into a list the court renumbers, so it is
# safe to keep. A plan that has one can file into that case directly.
case_tracking_id = models.CharField(max_length=255, blank=True)
docket_number = models.CharField(max_length=255, blank=True)
case_title = models.CharField(max_length=500, blank=True)

# {item_id: {"label": str, "requirement": "always|usually|sometimes",
# "description": str (optional), "status": "|have|filed|later",
# "due_date": "YYYY-MM-DD" (optional)}}
checklist = models.JSONField(default=dict, blank=True)

# What this kind of filing is about, in the partner's words, snapshotted the
# same way and for the same reason as the checklist:
# {"summary": str, "learn_more_url": str, "learn_more_label": str}
guidance = models.JSONField(default=dict, blank=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", "jurisdiction"], name="plan_user_jurisdiction_idx"),
]

def __str__(self):
return self.title or f"Filing plan #{self.pk}"

@property
def is_linked_to_a_case(self) -> bool:
return bool(self.case_tracking_id and self.docket_number)


class FilingDraft(models.Model):
"""Durable aggregate for a single in-progress or submitted court filing."""

Expand All @@ -62,6 +134,15 @@ class Status(models.TextChoices):
on_delete=models.CASCADE,
related_name="filing_drafts",
)
# The matter this filing belongs to, when the filer has one. A plan can
# gather several filings over time; losing the plan must not lose the filing.
plan = models.ForeignKey(
"FilingPlan",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="filing_drafts",
)
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(
Expand Down Expand Up @@ -108,6 +189,10 @@ class Status(models.TextChoices):
optional_services = models.JSONField(default=list, blank=True)
extracted_guesses = models.JSONField(default=dict, blank=True)
document_checklist_acknowledged = models.BooleanField(default=False)
# The side of the case this filer is on, when the case type distinguishes
# them (see FilingPlan.filer_role). Held here as well as on the plan so the
# question can be answered before a plan exists.
filer_role = models.CharField(max_length=60, blank=True)
# The dollar amount at stake, required by the EFSP when any document's
# filing type is flagged "amountincontroversy: Required". Stored as text
# (like the fee fields) since it's echoed back to the API rather than
Expand Down Expand Up @@ -178,6 +263,12 @@ class Role(models.TextChoices):
# type; case_questions asks for the dollar amount if any document needs it.
filing_requires_amount_in_controversy = models.BooleanField(default=False)

# The plan checklist item this document answers, when the filer said which
# one it is. It is how "I have my fee waiver" becomes "my fee waiver is in
# this envelope", so the checklist can stop asking and the review step can
# warn about anything the filer has but has not attached.
checklist_item_id = models.CharField(max_length=100, blank=True)

courtesy_copy_email = models.EmailField(blank=True)
# Codes selected from the court's optional-services list for this document
# (e.g. a certified copy), scoped per document since each can have its own
Expand Down
Loading