finishing QDRO section

This commit is contained in:
HotSwapp
2025-08-15 17:19:51 -05:00
parent 006ef3d7b1
commit abc7f289d1
22 changed files with 2753 additions and 46 deletions

View File

@@ -73,6 +73,7 @@ class UserCreate(BaseModel):
last_name: Optional[str] = None
is_admin: bool = False
is_active: bool = True
is_approver: bool = False
class UserUpdate(BaseModel):
"""Update user information"""
@@ -82,6 +83,7 @@ class UserUpdate(BaseModel):
last_name: Optional[str] = None
is_admin: Optional[bool] = None
is_active: Optional[bool] = None
is_approver: Optional[bool] = None
class UserResponse(BaseModel):
"""User response model"""
@@ -92,6 +94,7 @@ class UserResponse(BaseModel):
last_name: Optional[str]
is_admin: bool
is_active: bool
is_approver: bool
last_login: Optional[datetime]
created_at: Optional[datetime]
updated_at: Optional[datetime]
@@ -103,6 +106,44 @@ class PasswordReset(BaseModel):
new_password: str = Field(..., min_length=6)
confirm_password: str = Field(..., min_length=6)
# Approver management
class ApproverToggle(BaseModel):
is_approver: bool
@router.post("/users/{user_id}/approver", response_model=UserResponse)
async def set_user_approver(
user_id: int,
payload: ApproverToggle,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
"""Admin-only toggle for user approver role with audit logging."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
previous = bool(getattr(user, "is_approver", False))
user.is_approver = bool(payload.is_approver)
user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
if previous != user.is_approver:
try:
audit_service.log_user_action(
db=db,
action="UPDATE",
target_user=user,
acting_user=current_user,
changes={"is_approver": {"from": previous, "to": user.is_approver}},
request=request,
)
except Exception:
pass
return user
class SystemSetting(BaseModel):
"""System setting model"""
setting_key: str
@@ -115,6 +156,56 @@ class SettingUpdate(BaseModel):
setting_value: str
description: Optional[str] = None
# ------------------------------
# QDRO Notification Route Models
# ------------------------------
class NotificationRoute(BaseModel):
scope: str = Field(description="file or plan")
identifier: str = Field(description="file_no when scope=file, plan_id when scope=plan")
email_to: Optional[str] = None
webhook_url: Optional[str] = None
webhook_secret: Optional[str] = None
def _route_keys(scope: str, identifier: str) -> dict[str, str]:
if scope not in {"file", "plan"}:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope; expected 'file' or 'plan'")
return {
"email": f"notifications.qdro.email.to.{scope}.{identifier}",
"webhook_url": f"notifications.qdro.webhook.url.{scope}.{identifier}",
"webhook_secret": f"notifications.qdro.webhook.secret.{scope}.{identifier}",
}
def _get_setting(db: Session, key: str) -> Optional[str]:
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
return row.setting_value if row else None
def _upsert_setting(db: Session, key: str, value: Optional[str]) -> None:
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
if value is None or value == "":
if row:
db.delete(row)
db.commit()
return
if row:
row.setting_value = value
db.commit()
return
row = SystemSetup(setting_key=key, setting_value=value, description=f"Auto: {key}")
db.add(row)
db.commit()
def _delete_setting(db: Session, key: str) -> None:
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
if row:
db.delete(row)
db.commit()
class AuditLogEntry(BaseModel):
"""Audit log entry"""
id: int
@@ -726,6 +817,7 @@ async def create_user(
hashed_password=hashed_password,
is_admin=user_data.is_admin,
is_active=user_data.is_active,
is_approver=user_data.is_approver,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
@@ -807,7 +899,8 @@ async def update_user(
"first_name": user.first_name,
"last_name": user.last_name,
"is_admin": user.is_admin,
"is_active": user.is_active
"is_active": user.is_active,
"is_approver": user.is_approver,
}
# Update user fields
@@ -1063,6 +1156,85 @@ async def delete_setting(
return {"message": "Setting deleted successfully"}
# ------------------------------
# QDRO Notification Routing CRUD
# ------------------------------
@router.get("/qdro/notification-routes")
async def list_qdro_notification_routes(
scope: Optional[str] = Query(None, description="Optional filter: file or plan"),
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
q = db.query(SystemSetup).filter(SystemSetup.setting_key.like("notifications.qdro.%"))
rows = q.all()
# Build map of identifier -> route
route_map: dict[tuple[str, str], dict[str, Optional[str]]] = {}
for r in rows:
key = r.setting_key
parts = key.split(".")
# notifications qdro <type> <to|url|secret> <scope> <identifier>
if len(parts) < 7:
# Example: notifications.qdro.email.to.file.{id}
# parts: [notifications, qdro, email, to, file, {id}]
pass
if len(parts) >= 6 and parts[0] == "notifications" and parts[1] == "qdro":
typ = parts[2]
field = parts[3]
sc = parts[4]
ident = ".".join(parts[5:]) # support dots in identifiers just in case
if scope and sc != scope:
continue
route = route_map.setdefault((sc, ident), {"scope": sc, "identifier": ident, "email_to": None, "webhook_url": None, "webhook_secret": None})
if typ == "email" and field == "to":
route["email_to"] = r.setting_value
elif typ == "webhook" and field == "url":
route["webhook_url"] = r.setting_value
elif typ == "webhook" and field == "secret":
route["webhook_secret"] = r.setting_value
# Format list
out = [
{
"scope": sc,
"identifier": ident,
"email_to": data.get("email_to"),
"webhook_url": data.get("webhook_url"),
"webhook_secret": data.get("webhook_secret"),
}
for (sc, ident), data in route_map.items()
]
return {"items": out, "total": len(out)}
@router.post("/qdro/notification-routes")
async def upsert_qdro_notification_route(
payload: NotificationRoute,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
keys = _route_keys(payload.scope, payload.identifier)
_upsert_setting(db, keys["email"], payload.email_to)
_upsert_setting(db, keys["webhook_url"], payload.webhook_url)
# Preserve existing secret unless a new value is provided
if payload.webhook_secret is not None and payload.webhook_secret != "":
_upsert_setting(db, keys["webhook_secret"], payload.webhook_secret)
return {"message": "Route saved"}
@router.delete("/qdro/notification-routes/{scope}/{identifier}")
async def delete_qdro_notification_route(
scope: str,
identifier: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user),
):
keys = _route_keys(scope, identifier)
_delete_setting(db, keys["email"])
_delete_setting(db, keys["webhook_url"])
_delete_setting(db, keys["webhook_secret"])
return {"message": "Route deleted"}
# Database Maintenance and Lookup Management
@router.get("/lookups/tables")

View File

@@ -52,7 +52,13 @@ class PaginatedSchedulesResponse(BaseModel):
total: int
@router.get("/schedules", response_model=Union[List[ScheduleResponse], PaginatedSchedulesResponse])
@router.get(
"/schedules",
response_model=Union[List[ScheduleResponse], PaginatedSchedulesResponse],
summary="List pension schedules for a file",
description="Filter by file number, date range, version, numeric ranges, and optional tokenized search (version, frequency). Supports pagination, sorting, and optional total count.",
tags=["pensions", "pensions-schedules"],
)
async def list_pension_schedules(
file_no: str = Query(..., description="Filter by file number"),
start: Optional[date] = Query(None, description="Start date (inclusive) for vests_on"),
@@ -123,7 +129,14 @@ class ScheduleUpdate(BaseModel):
frequency: Optional[str] = None
@router.post("/schedules", response_model=ScheduleResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/schedules",
response_model=ScheduleResponse,
status_code=status.HTTP_201_CREATED,
summary="Create pension schedule",
description="Create a new pension schedule row for a file.",
tags=["pensions", "pensions-schedules"],
)
async def create_pension_schedule(
payload: ScheduleCreate,
db: Session = Depends(get_db),
@@ -142,7 +155,13 @@ async def create_pension_schedule(
return row
@router.get("/schedules/{row_id}", response_model=ScheduleResponse)
@router.get(
"/schedules/{row_id}",
response_model=ScheduleResponse,
summary="Get pension schedule",
description="Fetch a single pension schedule row by ID.",
tags=["pensions", "pensions-schedules"],
)
async def get_pension_schedule(
row_id: int,
db: Session = Depends(get_db),
@@ -154,7 +173,13 @@ async def get_pension_schedule(
return row
@router.put("/schedules/{row_id}", response_model=ScheduleResponse)
@router.put(
"/schedules/{row_id}",
response_model=ScheduleResponse,
summary="Update pension schedule",
description="Update fields on an existing pension schedule.",
tags=["pensions", "pensions-schedules"],
)
async def update_pension_schedule(
row_id: int,
payload: ScheduleUpdate,
@@ -171,7 +196,13 @@ async def update_pension_schedule(
return row
@router.delete("/schedules/{row_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/schedules/{row_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete pension schedule",
description="Delete a pension schedule row by ID.",
tags=["pensions", "pensions-schedules"],
)
async def delete_pension_schedule(
row_id: int,
db: Session = Depends(get_db),
@@ -208,7 +239,13 @@ class PaginatedMarriagesResponse(BaseModel):
total: int
@router.get("/marriages", response_model=Union[List[MarriageResponse], PaginatedMarriagesResponse])
@router.get(
"/marriages",
response_model=Union[List[MarriageResponse], PaginatedMarriagesResponse],
summary="List marriage history",
description="Filter by file, date range, version, numeric ranges, and optional tokenized search (version, spouse_name, notes). Supports pagination and sorting.",
tags=["pensions", "pensions-marriages"],
)
async def list_marriages(
file_no: str = Query(..., description="Filter by file number"),
start: Optional[date] = Query(None, description="Start date (inclusive) for married_from"),
@@ -307,7 +344,14 @@ class MarriageUpdate(BaseModel):
marital_percent: Optional[float] = None
@router.post("/marriages", response_model=MarriageResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/marriages",
response_model=MarriageResponse,
status_code=status.HTTP_201_CREATED,
summary="Create marriage history row",
description="Create a new marriage history record for a file.",
tags=["pensions", "pensions-marriages"],
)
async def create_marriage(
payload: MarriageCreate,
db: Session = Depends(get_db),
@@ -320,7 +364,13 @@ async def create_marriage(
return row
@router.get("/marriages/{row_id}", response_model=MarriageResponse)
@router.get(
"/marriages/{row_id}",
response_model=MarriageResponse,
summary="Get marriage history row",
description="Fetch a single marriage history record by ID.",
tags=["pensions", "pensions-marriages"],
)
async def get_marriage(
row_id: int,
db: Session = Depends(get_db),
@@ -332,7 +382,13 @@ async def get_marriage(
return row
@router.put("/marriages/{row_id}", response_model=MarriageResponse)
@router.put(
"/marriages/{row_id}",
response_model=MarriageResponse,
summary="Update marriage history row",
description="Update fields on an existing marriage history record.",
tags=["pensions", "pensions-marriages"],
)
async def update_marriage(
row_id: int,
payload: MarriageUpdate,
@@ -349,7 +405,13 @@ async def update_marriage(
return row
@router.delete("/marriages/{row_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/marriages/{row_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete marriage history row",
description="Delete a marriage history record by ID.",
tags=["pensions", "pensions-marriages"],
)
async def delete_marriage(
row_id: int,
db: Session = Depends(get_db),
@@ -386,7 +448,13 @@ class PaginatedDeathResponse(BaseModel):
total: int
@router.get("/death-benefits", response_model=Union[List[DeathResponse], PaginatedDeathResponse])
@router.get(
"/death-benefits",
response_model=Union[List[DeathResponse], PaginatedDeathResponse],
summary="List death benefits",
description="Filter by file, date range, version, numeric ranges, and optional tokenized search (version, beneficiary_name, benefit_type, notes). Supports pagination and sorting.",
tags=["pensions", "pensions-death"],
)
async def list_death_benefits(
file_no: str = Query(..., description="Filter by file number"),
start: Optional[date] = Query(None, description="Start date (inclusive) for created_at"),
@@ -506,7 +574,14 @@ class DeathUpdate(BaseModel):
disc2: Optional[float] = None
@router.post("/death-benefits", response_model=DeathResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/death-benefits",
response_model=DeathResponse,
status_code=status.HTTP_201_CREATED,
summary="Create death benefit",
description="Create a new death benefit record for a file.",
tags=["pensions", "pensions-death"],
)
async def create_death_benefit(
payload: DeathCreate,
db: Session = Depends(get_db),
@@ -519,7 +594,13 @@ async def create_death_benefit(
return row
@router.get("/death-benefits/{row_id}", response_model=DeathResponse)
@router.get(
"/death-benefits/{row_id}",
response_model=DeathResponse,
summary="Get death benefit",
description="Fetch a single death benefit record by ID.",
tags=["pensions", "pensions-death"],
)
async def get_death_benefit(
row_id: int,
db: Session = Depends(get_db),
@@ -531,7 +612,13 @@ async def get_death_benefit(
return row
@router.put("/death-benefits/{row_id}", response_model=DeathResponse)
@router.put(
"/death-benefits/{row_id}",
response_model=DeathResponse,
summary="Update death benefit",
description="Update fields on an existing death benefit record.",
tags=["pensions", "pensions-death"],
)
async def update_death_benefit(
row_id: int,
payload: DeathUpdate,
@@ -548,7 +635,13 @@ async def update_death_benefit(
return row
@router.delete("/death-benefits/{row_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/death-benefits/{row_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete death benefit",
description="Delete a death benefit record by ID.",
tags=["pensions", "pensions-death"],
)
async def delete_death_benefit(
row_id: int,
db: Session = Depends(get_db),
@@ -669,7 +762,13 @@ class PensionDetailResponse(BaseModel):
separations: PaginatedSeparationsResponse
@router.get("/{file_no}/detail", response_model=PensionDetailResponse)
@router.get(
"/{file_no}/detail",
response_model=PensionDetailResponse,
summary="Pension detail with nested lists",
description="Return a representative Pension record for a file along with nested lists (schedules, marriages, death benefits, separations), each with independent pagination, sorting, and filtering controls.",
tags=["pensions", "pensions-detail"],
)
async def get_pension_detail(
file_no: str,
# Schedules controls
@@ -895,7 +994,14 @@ class PensionUpdate(BaseModel):
tax_rate: Optional[float] = Field(default=None, ge=0, le=100)
@router.post("/", response_model=PensionResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/",
response_model=PensionResponse,
status_code=status.HTTP_201_CREATED,
summary="Create Pension",
description="Create a main Pension record for a file.",
tags=["pensions", "pensions-main"],
)
async def create_pension(
payload: PensionCreate,
db: Session = Depends(get_db),
@@ -908,7 +1014,13 @@ async def create_pension(
return row
@router.get("/{pension_id}", response_model=PensionResponse)
@router.get(
"/{pension_id}",
response_model=PensionResponse,
summary="Get Pension",
description="Fetch a main Pension record by ID.",
tags=["pensions", "pensions-main"],
)
async def get_pension(
pension_id: int,
db: Session = Depends(get_db),
@@ -920,7 +1032,13 @@ async def get_pension(
return row
@router.put("/{pension_id}", response_model=PensionResponse)
@router.put(
"/{pension_id}",
response_model=PensionResponse,
summary="Update Pension",
description="Update fields on an existing Pension record.",
tags=["pensions", "pensions-main"],
)
async def update_pension(
pension_id: int,
payload: PensionUpdate,
@@ -937,7 +1055,13 @@ async def update_pension(
return row
@router.delete("/{pension_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete(
"/{pension_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Pension",
description="Delete a Pension record by ID.",
tags=["pensions", "pensions-main"],
)
async def delete_pension(
pension_id: int,
db: Session = Depends(get_db),

694
app/api/qdros.py Normal file
View File

@@ -0,0 +1,694 @@
"""
QDRO API
Endpoints:
- POST /api/qdros
- GET /api/qdros/{file_no}
- GET /api/qdros/item/{qdro_id}
- PUT /api/qdros/{qdro_id}
- DELETE /api/qdros/{qdro_id}
- POST /api/qdros/{qdro_id}/versions
- GET /api/qdros/{qdro_id}/versions
- POST /api/qdros/{qdro_id}/calculate-division
- POST /api/qdros/{qdro_id}/generate-document
- GET /api/qdros/{qdro_id}/communications
- POST /api/qdros/{qdro_id}/communications
Plan Info:
- POST /api/plan-info
- GET /api/plan-info
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Union
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.database.base import get_db
from app.auth.security import get_current_user
from app.models.user import User
from app.models.files import File
from app.models.qdro import QDRO, QDROVersion, QDROCommunication
from app.models.lookups import PlanInfo
from app.services.audit import audit_service
from app.services.query_utils import apply_sorting, paginate_with_total
from app.services.notification import notification_service, resolve_qdro_routes
from app.models.templates import DocumentTemplate, DocumentTemplateVersion
from app.services.storage import get_default_storage
from app.services.template_merge import extract_tokens_from_bytes, build_context, resolve_tokens, render_docx
router = APIRouter()
class QDROBase(BaseModel):
file_no: str
version: str = "01"
plan_id: Optional[str] = None
form_name: Optional[str] = None
status: str = "DRAFT"
case_number: Optional[str] = None
notes: Optional[str] = None
# Dates
judgment_date: Optional[date] = None
valuation_date: Optional[date] = None
married_on: Optional[date] = None
# Parties
pet: Optional[str] = None
res: Optional[str] = None
# Award info (percent string like "50%" or free text)
percent_awarded: Optional[str] = None
class QDROCreate(QDROBase):
pass
class QDROUpdate(BaseModel):
version: Optional[str] = None
plan_id: Optional[str] = None
form_name: Optional[str] = None
status: Optional[str] = None
case_number: Optional[str] = None
notes: Optional[str] = None
judgment_date: Optional[date] = None
valuation_date: Optional[date] = None
married_on: Optional[date] = None
pet: Optional[str] = None
res: Optional[str] = None
percent_awarded: Optional[str] = None
approved_date: Optional[date] = None
filed_date: Optional[date] = None
class QDROResponse(QDROBase):
id: int
approval_status: Optional[str] = None
approved_date: Optional[date] = None
filed_date: Optional[date] = None
model_config = ConfigDict(from_attributes=True)
class PaginatedQDROResponse(BaseModel):
items: List[QDROResponse]
total: int
@router.post("/qdros", response_model=QDROResponse, summary="Create a new QDRO linked to a file")
async def create_qdro(
payload: QDROCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
file_obj = db.query(File).filter(File.file_no == payload.file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
allowed = {c.name for c in QDRO.__table__.columns}
data = {k: v for k, v in payload.model_dump(exclude_unset=True).items() if k in allowed}
qdro = QDRO(**data)
db.add(qdro)
db.commit()
db.refresh(qdro)
try:
audit_service.log_action(db, action="CREATE", resource_type="QDRO", user=current_user, resource_id=qdro.id, details={"file_no": qdro.file_no})
except Exception:
pass
return qdro
@router.get("/qdros/{file_no}", response_model=Union[List[QDROResponse], PaginatedQDROResponse], summary="List QDROs by file")
async def list_qdros_by_file(
file_no: str,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
sort_by: Optional[str] = Query("updated", description="Sort by: updated|created|version|status"),
sort_dir: Optional[str] = Query("desc", description="Sort direction: asc|desc"),
include_total: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = db.query(QDRO).filter(QDRO.file_no == file_no)
q = apply_sorting(
q,
sort_by,
sort_dir,
allowed={
"updated": [QDRO.updated_at, QDRO.id],
"created": [QDRO.created_at, QDRO.id],
"version": [QDRO.version],
"status": [QDRO.status],
},
)
items, total = paginate_with_total(q, skip, limit, include_total)
if include_total:
return {"items": items, "total": total or 0}
return items
@router.get("/qdros/item/{qdro_id}", response_model=QDROResponse, summary="Get a QDRO by id")
async def get_qdro(
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
return qdro
@router.put("/qdros/{qdro_id}", response_model=QDROResponse, summary="Update a QDRO")
async def update_qdro(
qdro_id: int,
payload: QDROUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
allowed = {c.name for c in QDRO.__table__.columns}
changes = {}
for k, v in payload.model_dump(exclude_unset=True).items():
if k in allowed:
setattr(qdro, k, v)
changes[k] = v
db.commit()
db.refresh(qdro)
try:
audit_service.log_action(db, action="UPDATE", resource_type="QDRO", user=current_user, resource_id=qdro.id, details=changes)
except Exception:
pass
return qdro
# -----------------------------
# Workflow: status transitions
# -----------------------------
class TransitionRequest(BaseModel):
target_status: str
reason: Optional[str] = None
notify: bool = False
# Optional dates to set on certain transitions
draft_out_date: Optional[date] = None
approved_date: Optional[date] = None
filed_date: Optional[date] = None
class SimpleWorkflowRequest(BaseModel):
reason: Optional[str] = None
notify: bool = False
effective_date: Optional[date] = None
# Allowed transitions graph
ALLOWED_TRANSITIONS: Dict[str, set[str]] = {
"DRAFT": {"APPROVAL_PENDING"},
"APPROVAL_PENDING": {"APPROVED"},
"APPROVED": {"FILED"},
}
def _emit_qdro_notification(db: Session, event_type: str, payload: Dict[str, Any]) -> None:
try:
# Enrich with routing from DB (SystemSetup)
routes = resolve_qdro_routes(
db,
file_no=str(payload.get("file_no")) if payload.get("file_no") else None,
plan_id=str(payload.get("plan_id")) if payload.get("plan_id") else None,
)
enriched = dict(payload)
if routes.get("email_to"):
enriched["__notify_to"] = routes["email_to"]
enriched["__notify_override"] = True
if routes.get("webhook_url"):
enriched["__webhook_url"] = routes["webhook_url"]
if routes.get("webhook_secret"):
enriched["__webhook_secret"] = routes["webhook_secret"]
enriched["__webhook_override"] = True
notification_service.emit(event_type, enriched)
except Exception:
# Never block on notifications
pass
def _perform_transition(db: Session, qdro: QDRO, target_status: str, meta: Dict[str, Any]) -> Dict[str, Any]:
current = (qdro.status or "DRAFT").upper()
target = (target_status or "").upper()
if target not in ALLOWED_TRANSITIONS.get(current, set()):
raise HTTPException(status_code=400, detail=f"Transition not allowed: {current} -> {target}")
# Apply status and relevant dates
qdro.status = target
qdro.approval_status = target
now = date.today()
changes: Dict[str, Any] = {"from": current, "to": target}
if target == "APPROVAL_PENDING":
set_date = meta.get("draft_out_date") or now
if not isinstance(set_date, date):
raise HTTPException(status_code=400, detail="Invalid draft_out_date")
if qdro.draft_out != set_date:
qdro.draft_out = set_date
changes["draft_out"] = set_date.isoformat()
elif target == "APPROVED":
set_date = meta.get("approved_date") or now
if not isinstance(set_date, date):
raise HTTPException(status_code=400, detail="Invalid approved_date")
if qdro.approved_date != set_date:
qdro.approved_date = set_date
changes["approved_date"] = set_date.isoformat()
# Mirror to legacy draft_apr
if qdro.draft_apr != set_date:
qdro.draft_apr = set_date
changes["draft_apr"] = set_date.isoformat()
elif target == "FILED":
set_date = meta.get("filed_date") or now
if not isinstance(set_date, date):
raise HTTPException(status_code=400, detail="Invalid filed_date")
if qdro.filed_date != set_date:
qdro.filed_date = set_date
changes["filed_date"] = set_date.isoformat()
# Persist
db.commit()
db.refresh(qdro)
return changes
@router.post("/qdros/{qdro_id}/transition", response_model=QDROResponse, summary="Transition QDRO status with validation")
async def transition_qdro(
qdro_id: int,
payload: TransitionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
# Authorization: approving/file transitions require approver or admin
target_upper = (payload.target_status or "").upper()
if target_upper in {"APPROVED", "FILED"} and not (getattr(current_user, "is_admin", False) or getattr(current_user, "is_approver", False)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
changes = _perform_transition(
db,
qdro,
payload.target_status,
{
"draft_out_date": payload.draft_out_date,
"approved_date": payload.approved_date,
"filed_date": payload.filed_date,
},
)
details = {
**changes,
"reason": payload.reason,
}
try:
audit_service.log_action(
db,
action="STATUS_TRANSITION",
resource_type="QDRO",
user=current_user,
resource_id=qdro.id,
details=details,
)
except Exception:
pass
if payload.notify:
_emit_qdro_notification(
db,
"QDRO_STATUS_CHANGED",
{
"qdro_id": qdro.id,
"file_no": qdro.file_no,
"plan_id": qdro.plan_id,
**details,
},
)
return qdro
@router.post("/qdros/{qdro_id}/submit-for-approval", response_model=QDROResponse, summary="Move QDRO to APPROVAL_PENDING")
async def submit_for_approval(
qdro_id: int,
payload: SimpleWorkflowRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
changes = _perform_transition(db, qdro, "APPROVAL_PENDING", {"draft_out_date": payload.effective_date})
details = {**changes, "reason": payload.reason}
try:
audit_service.log_action(db, action="STATUS_TRANSITION", resource_type="QDRO", user=current_user, resource_id=qdro.id, details=details)
except Exception:
pass
if payload.notify:
_emit_qdro_notification(db, "QDRO_STATUS_CHANGED", {"qdro_id": qdro.id, "file_no": qdro.file_no, "plan_id": qdro.plan_id, **details})
return qdro
@router.post("/qdros/{qdro_id}/approve", response_model=QDROResponse, summary="Approve QDRO")
async def approve_qdro(
qdro_id: int,
payload: SimpleWorkflowRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
# Authorization: approver or admin
if not (getattr(current_user, "is_admin", False) or getattr(current_user, "is_approver", False)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
changes = _perform_transition(db, qdro, "APPROVED", {"approved_date": payload.effective_date})
details = {**changes, "reason": payload.reason}
try:
audit_service.log_action(db, action="STATUS_TRANSITION", resource_type="QDRO", user=current_user, resource_id=qdro.id, details=details)
except Exception:
pass
if payload.notify:
_emit_qdro_notification(db, "QDRO_STATUS_CHANGED", {"qdro_id": qdro.id, "file_no": qdro.file_no, "plan_id": qdro.plan_id, **details})
return qdro
@router.post("/qdros/{qdro_id}/file", response_model=QDROResponse, summary="Mark QDRO as filed")
async def file_qdro(
qdro_id: int,
payload: SimpleWorkflowRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
# Authorization: approver or admin
if not (getattr(current_user, "is_admin", False) or getattr(current_user, "is_approver", False)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
changes = _perform_transition(db, qdro, "FILED", {"filed_date": payload.effective_date})
details = {**changes, "reason": payload.reason}
try:
audit_service.log_action(db, action="STATUS_TRANSITION", resource_type="QDRO", user=current_user, resource_id=qdro.id, details=details)
except Exception:
pass
if payload.notify:
_emit_qdro_notification(db, "QDRO_STATUS_CHANGED", {"qdro_id": qdro.id, "file_no": qdro.file_no, "plan_id": qdro.plan_id, **details})
return qdro
@router.delete("/qdros/{qdro_id}", summary="Delete a QDRO")
async def delete_qdro(
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
db.delete(qdro)
db.commit()
try:
audit_service.log_action(db, action="DELETE", resource_type="QDRO", user=current_user, resource_id=qdro_id, details={"file_no": qdro.file_no})
except Exception:
pass
return {"message": "QDRO deleted"}
class VersionCreate(BaseModel):
version_label: str = Field(default="01", max_length=20)
status: Optional[str] = Field(default="DRAFT", max_length=45)
class VersionResponse(BaseModel):
id: int
qdro_id: int
version_label: str
status: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
@router.post("/qdros/{qdro_id}/versions", response_model=VersionResponse, summary="Create a new version snapshot of a QDRO")
async def create_qdro_version(
qdro_id: int,
payload: VersionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
ver = QDROVersion(qdro_id=qdro.id, version_label=payload.version_label, status=payload.status, content=qdro.content)
db.add(ver)
db.commit()
db.refresh(ver)
try:
audit_service.log_action(db, action="VERSION_CREATE", resource_type="QDRO", user=current_user, resource_id=qdro_id, details={"version": payload.version_label})
except Exception:
pass
return ver
@router.get("/qdros/{qdro_id}/versions", response_model=List[VersionResponse], summary="List versions for a QDRO")
async def list_qdro_versions(
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
versions = db.query(QDROVersion).filter(QDROVersion.qdro_id == qdro_id).order_by(QDROVersion.created_at.desc()).all()
return versions
class DivisionCalcRequest(BaseModel):
account_balance: float
percent: Optional[float] = Field(default=None, ge=0.0, le=100.0)
amount: Optional[float] = Field(default=None, ge=0.0)
save_percent_string: bool = False
class DivisionCalcResponse(BaseModel):
percent: float
amount: float
@router.post("/qdros/{qdro_id}/calculate-division", response_model=DivisionCalcResponse, summary="Calculate division by percent or amount")
async def calculate_division(
qdro_id: int,
payload: DivisionCalcRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
if (payload.percent is None) == (payload.amount is None):
raise HTTPException(status_code=400, detail="Provide exactly one of percent or amount")
if payload.percent is not None:
amount = round((payload.percent / 100.0) * payload.account_balance, 2)
percent = float(payload.percent)
else:
amount = float(payload.amount or 0.0)
if payload.account_balance <= 0:
raise HTTPException(status_code=400, detail="account_balance must be > 0 when amount provided")
percent = round((amount / payload.account_balance) * 100.0, 4)
if payload.save_percent_string:
try:
qdro.percent_awarded = f"{percent:.4g}%"
db.commit()
except Exception:
db.rollback()
try:
audit_service.log_action(db, action="CALCULATE", resource_type="QDRO", user=current_user, resource_id=qdro_id, details={"percent": percent, "amount": amount})
except Exception:
pass
return DivisionCalcResponse(percent=percent, amount=amount)
class GenerateRequest(BaseModel):
template_id: int
version_id: Optional[int] = None
context: Dict[str, Any] = Field(default_factory=dict)
class GenerateResponse(BaseModel):
resolved: Dict[str, Any]
unresolved: List[str]
output_mime_type: str
output_size: int
@router.post("/qdros/{qdro_id}/generate-document", response_model=GenerateResponse, summary="Generate a QDRO document using the template system")
async def generate_qdro_document(
qdro_id: int,
payload: GenerateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
# Locate template and version
tpl = db.query(DocumentTemplate).filter(DocumentTemplate.id == payload.template_id).first()
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
version_id = payload.version_id or tpl.current_version_id
if not version_id:
raise HTTPException(status_code=400, detail="Template has no versions")
ver = db.query(DocumentTemplateVersion).filter(DocumentTemplateVersion.id == version_id).first()
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
storage = get_default_storage()
content = storage.open_bytes(ver.storage_path)
tokens = extract_tokens_from_bytes(content)
# Build a rich context with file and qdro details
file_obj = db.query(File).filter(File.file_no == qdro.file_no).first()
base_ctx: Dict[str, Any] = {
"FILE_NO": qdro.file_no,
"QDRO_VERSION": qdro.version,
"QDRO_STATUS": qdro.status,
"CASE_NUMBER": qdro.case_number,
"PETITIONER": qdro.pet,
"RESPONDENT": qdro.res,
"PERCENT_AWARDED": qdro.percent_awarded,
}
if file_obj and file_obj.owner:
base_ctx.update({
"CLIENT_FIRST": getattr(file_obj.owner, "first", ""),
"CLIENT_LAST": getattr(file_obj.owner, "last", ""),
"CLIENT_FULL": f"{getattr(file_obj.owner, 'first', '') or ''} {getattr(file_obj.owner, 'last', '')}".strip(),
"MATTER": file_obj.regarding,
})
# Merge with provided context
context = build_context({**base_ctx, **(payload.context or {})})
resolved, unresolved = resolve_tokens(db, tokens, context)
output_bytes = content
output_mime = ver.mime_type
if ver.mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
output_bytes = render_docx(content, resolved)
output_mime = ver.mime_type
try:
audit_service.log_action(db, action="GENERATE", resource_type="QDRO", user=current_user, resource_id=qdro_id, details={"template_id": payload.template_id, "version_id": version_id, "unresolved": unresolved})
except Exception:
pass
return GenerateResponse(resolved=resolved, unresolved=unresolved, output_mime_type=output_mime, output_size=len(output_bytes))
class PlanInfoCreate(BaseModel):
plan_id: str
plan_name: str
plan_type: Optional[str] = None
sponsor: Optional[str] = None
administrator: Optional[str] = None
address1: Optional[str] = None
address2: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
zip_code: Optional[str] = None
phone: Optional[str] = None
notes: Optional[str] = None
class PlanInfoResponse(PlanInfoCreate):
model_config = ConfigDict(from_attributes=True)
@router.post("/plan-info", response_model=PlanInfoResponse, summary="Create plan information")
async def create_plan_info(
payload: PlanInfoCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
exists = db.query(PlanInfo).filter(PlanInfo.plan_id == payload.plan_id).first()
if exists:
raise HTTPException(status_code=400, detail="Plan already exists")
plan = PlanInfo(**payload.model_dump())
db.add(plan)
db.commit()
db.refresh(plan)
try:
audit_service.log_action(db, action="CREATE", resource_type="PLAN_INFO", user=current_user, resource_id=plan.plan_id)
except Exception:
pass
return plan
@router.get("/plan-info", response_model=List[PlanInfoResponse], summary="List plan information")
async def list_plan_info(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
rows = db.query(PlanInfo).order_by(PlanInfo.plan_name.asc()).all()
return rows
class CommunicationCreate(BaseModel):
channel: Optional[str] = Field(default=None, description="email|phone|letter|fax|portal")
subject: Optional[str] = None
message: Optional[str] = None
contact_name: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
status: Optional[str] = None
class CommunicationResponse(CommunicationCreate):
id: int
qdro_id: int
model_config = ConfigDict(from_attributes=True)
@router.get("/qdros/{qdro_id}/communications", response_model=List[CommunicationResponse], summary="List QDRO communications")
async def list_communications(
qdro_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
rows = db.query(QDROCommunication).filter(QDROCommunication.qdro_id == qdro_id).order_by(QDROCommunication.created_at.desc()).all()
return rows
@router.post("/qdros/{qdro_id}/communications", response_model=CommunicationResponse, summary="Create QDRO communication entry")
async def create_communication(
qdro_id: int,
payload: CommunicationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
qdro = db.query(QDRO).filter(QDRO.id == qdro_id).first()
if not qdro:
raise HTTPException(status_code=404, detail="QDRO not found")
comm = QDROCommunication(qdro_id=qdro_id, **payload.model_dump(exclude_unset=True))
db.add(comm)
db.commit()
db.refresh(comm)
try:
audit_service.log_action(db, action="COMM_CREATE", resource_type="QDRO", user=current_user, resource_id=qdro_id, details={"comm_id": comm.id, "channel": comm.channel})
except Exception:
pass
return comm

View File

@@ -13,6 +13,8 @@ from __future__ import annotations
from typing import List, Optional, Dict, Any, Union
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
from fastapi.responses import StreamingResponse
import os
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, exists
import hashlib
@@ -410,6 +412,49 @@ async def preview_template(
)
@router.get("/{template_id}/download")
async def download_template(
template_id: int,
version_id: Optional[int] = Query(None, description="Optional specific version id to download"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
tpl = db.query(DocumentTemplate).filter(DocumentTemplate.id == template_id).first()
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
# Determine which version to serve
resolved_version_id = version_id or tpl.current_version_id
if not resolved_version_id:
raise HTTPException(status_code=404, detail="Template has no approved version")
ver = (
db.query(DocumentTemplateVersion)
.filter(DocumentTemplateVersion.id == resolved_version_id, DocumentTemplateVersion.template_id == tpl.id)
.first()
)
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
storage = get_default_storage()
try:
content = storage.open_bytes(ver.storage_path)
except Exception:
raise HTTPException(status_code=404, detail="Stored file not found")
# Derive original filename from storage_path (uuid_prefix_originalname)
base = os.path.basename(ver.storage_path)
if "_" in base:
original_name = base.split("_", 1)[1]
else:
original_name = base
headers = {
"Content-Disposition": f"attachment; filename=\"{original_name}\"",
}
return StreamingResponse(iter([content]), media_type=ver.mime_type, headers=headers)
@router.get("/{template_id}/keywords", response_model=KeywordsResponse)
async def list_keywords(
template_id: int,