Files
delphi-database/app/api/qdros.py
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

709 lines
25 KiB
Python

"""
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 {})}, "file", qdro.file_no)
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
# Sanitize resolved values to ensure JSON-serializable output
def _json_sanitize(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, (list, tuple)):
return [_json_sanitize(v) for v in value]
if isinstance(value, dict):
return {k: _json_sanitize(v) for k, v in value.items()}
# Fallback: stringify unsupported types (e.g., functions)
return str(value)
sanitized_resolved = {k: _json_sanitize(v) for k, v in resolved.items()}
return GenerateResponse(resolved=sanitized_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