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,

View File

@@ -61,6 +61,21 @@ class Settings(BaseSettings):
cache_enabled: bool = False
redis_url: Optional[str] = None
# Notifications
notifications_enabled: bool = False
# Email settings (optional)
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_username: Optional[str] = None
smtp_password: Optional[str] = None
smtp_starttls: bool = True
notification_email_from: str = "no-reply@localhost"
# QDRO notification recipients (comma-separated emails)
qdro_notify_email_to: Optional[str] = None
# Webhook settings (optional)
qdro_notify_webhook_url: Optional[str] = None
qdro_notify_webhook_secret: Optional[str] = None
# pydantic-settings v2 configuration
model_config = SettingsConfigDict(
env_file=".env",

View File

@@ -117,6 +117,17 @@ def ensure_schema_updates(engine: Engine) -> None:
"separation_agreements": {
"version": "VARCHAR(10)",
},
# QDROs: add explicit created_at and workflow fields if missing
"qdros": {
"created_at": "DATETIME",
"approval_status": "VARCHAR(45)",
"approved_date": "DATE",
"filed_date": "DATE",
},
# Users: add approver flag
"users": {
"is_approver": "BOOLEAN",
},
}
with engine.begin() as conn:

View File

@@ -91,6 +91,8 @@ from app.api.support import router as support_router
from app.api.settings import router as settings_router
from app.api.mortality import router as mortality_router
from app.api.pensions import router as pensions_router
from app.api.templates import router as templates_router
from app.api.qdros import router as qdros_router
logger.info("Including API routers")
app.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
@@ -106,6 +108,8 @@ app.include_router(settings_router, prefix="/api/settings", tags=["settings"])
app.include_router(flexible_router, prefix="/api")
app.include_router(mortality_router, prefix="/api/mortality", tags=["mortality"])
app.include_router(pensions_router, prefix="/api/pensions", tags=["pensions"])
app.include_router(templates_router, prefix="/api/templates", tags=["templates"])
app.include_router(qdros_router, prefix="/api", tags=["qdros"])
@app.get("/", response_class=HTMLResponse)

View File

@@ -6,7 +6,7 @@ from .user import User
from .rolodex import Rolodex, Phone
from .files import File
from .ledger import Ledger
from .qdro import QDRO
from .qdro import QDRO, QDROVersion, QDROCommunication
from .audit import AuditLog, LoginAttempt, ImportAudit, ImportAuditFile
from .auth import RefreshToken
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable, Document
@@ -16,6 +16,7 @@ from .pensions import (
Pension, PensionSchedule, MarriageHistory, DeathBenefit,
SeparationAgreement, LifeTable, NumberTable, PensionResult
)
from .templates import DocumentTemplate, DocumentTemplateVersion, TemplateKeyword
from .lookups import (
Employee, FileType, FileStatus, TransactionType, TransactionCode,
State, GroupLookup, Footer, PlanInfo, FormIndex, FormList,
@@ -23,7 +24,7 @@ from .lookups import (
)
__all__ = [
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO", "QDROVersion", "QDROCommunication",
"AuditLog", "LoginAttempt", "ImportAudit", "ImportAuditFile", "RefreshToken",
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Document", "FlexibleImport",
"SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory",
@@ -31,5 +32,5 @@ __all__ = [
"SeparationAgreement", "LifeTable", "NumberTable", "PensionResult",
"Employee", "FileType", "FileStatus", "TransactionType", "TransactionCode",
"State", "GroupLookup", "Footer", "PlanInfo", "FormIndex", "FormList",
"PrinterSetup", "SystemSetup", "FormKeyword"
"PrinterSetup", "SystemSetup", "FormKeyword", "TemplateKeyword"
]

View File

@@ -1,8 +1,9 @@
"""
QDRO models based on legacy QDRO.SC analysis
"""
from sqlalchemy import Column, Integer, String, Date, Text, ForeignKey
from sqlalchemy import Column, Integer, String, Date, Text, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.models.base import BaseModel
@@ -15,8 +16,11 @@ class QDRO(BaseModel):
id = Column(Integer, primary_key=True, autoincrement=True)
file_no = Column(String(45), ForeignKey("files.file_no"), nullable=False)
version = Column(String(10), default="01") # Version of QDRO
plan_id = Column(String(45)) # Plan identifier
version = Column(String(10), default="01") # Version of QDRO (current working version)
plan_id = Column(String(45)) # Plan identifier (links to PlanInfo.plan_id)
# Timestamps (explicit created_at for sort consistency across APIs)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# CSV fields from legacy system
field1 = Column(String(100)) # ^1 field
@@ -55,12 +59,61 @@ class QDRO(BaseModel):
form_name = Column(String(200)) # Form/template name
# Additional fields
status = Column(String(45), default="DRAFT") # DRAFT, APPROVED, FILED, etc.
status = Column(String(45), default="DRAFT") # DRAFT, APPROVAL_PENDING, APPROVED, FILED, etc.
content = Column(Text) # Document content/template
notes = Column(Text) # Additional notes
# Court/cycle tracking (idempotent schema updater will add when missing)
approval_status = Column(String(45)) # Workflow status if different granularity is needed
approved_date = Column(Date)
filed_date = Column(Date)
# Relationships
file = relationship("File", back_populates="qdros")
versions = relationship("QDROVersion", back_populates="qdro", cascade="all, delete-orphan")
communications = relationship("QDROCommunication", back_populates="qdro", cascade="all, delete-orphan")
def __repr__(self):
return f"<QDRO(file_no='{self.file_no}', version='{self.version}', case_number='{self.case_number}')>"
class QDROVersion(BaseModel):
"""
Immutable snapshot of a QDRO at a point in time for version tracking.
"""
__tablename__ = "qdro_versions"
id = Column(Integer, primary_key=True, autoincrement=True)
qdro_id = Column(Integer, ForeignKey("qdros.id"), nullable=False, index=True)
version_label = Column(String(20), nullable=False, default="01")
status = Column(String(45), default="DRAFT")
content = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
qdro = relationship("QDRO", back_populates="versions")
def __repr__(self):
return f"<QDROVersion(qdro_id={self.qdro_id}, version='{self.version_label}', status='{self.status}')>"
class QDROCommunication(BaseModel):
"""
Track communications with plan administrators or other parties regarding a QDRO.
"""
__tablename__ = "qdro_communications"
id = Column(Integer, primary_key=True, autoincrement=True)
qdro_id = Column(Integer, ForeignKey("qdros.id"), nullable=False, index=True)
channel = Column(String(20)) # email | phone | letter | fax | portal
subject = Column(String(200))
message = Column(Text)
contact_name = Column(String(100))
contact_email = Column(String(200))
contact_phone = Column(String(50))
status = Column(String(45)) # sent | received | pending | escalated
created_at = Column(DateTime(timezone=True), server_default=func.now())
qdro = relationship("QDRO", back_populates="communications")
def __repr__(self):
return f"<QDROCommunication(qdro_id={self.qdro_id}, channel='{self.channel}', subject='{self.subject}')>"

98
app/models/templates.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Document Template and Version models
"""
from sqlalchemy import Column, Integer, String, Text, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class DocumentTemplate(BaseModel):
"""
High-level template metadata and current version pointer.
"""
__tablename__ = "document_templates"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
name = Column(String(200), unique=True, nullable=False, index=True)
description = Column(Text)
category = Column(String(100), index=True, default="GENERAL")
active = Column(Boolean, default=True, nullable=False)
# Creator username (optional)
created_by = Column(String(150), ForeignKey("users.username"), nullable=True)
# Pointer to the currently-approved version
current_version_id = Column(Integer, ForeignKey("document_template_versions.id", use_alter=True), nullable=True)
# Relationships
current_version = relationship(
"DocumentTemplateVersion",
foreign_keys=[current_version_id],
post_update=True,
primaryjoin="DocumentTemplate.current_version_id==DocumentTemplateVersion.id",
uselist=False,
)
versions = relationship(
"DocumentTemplateVersion",
back_populates="template",
cascade="all, delete-orphan",
order_by="desc(DocumentTemplateVersion.created_at)",
foreign_keys="DocumentTemplateVersion.template_id",
)
keywords = relationship(
"TemplateKeyword",
back_populates="template",
cascade="all, delete-orphan",
order_by="asc(TemplateKeyword.keyword)",
foreign_keys="TemplateKeyword.template_id",
)
class DocumentTemplateVersion(BaseModel):
"""
Template binary version metadata and storage location
"""
__tablename__ = "document_template_versions"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
template_id = Column(Integer, ForeignKey("document_templates.id"), nullable=False, index=True)
semantic_version = Column(String(50), nullable=False, index=True) # e.g., 1.0.0
storage_path = Column(String(512), nullable=False) # local path or S3 URI
mime_type = Column(String(100), nullable=False)
size = Column(Integer, nullable=False, default=0)
checksum = Column(String(64), nullable=False) # sha256 hex
changelog = Column(Text)
created_by = Column(String(150), ForeignKey("users.username"), nullable=True)
is_approved = Column(Boolean, default=True, nullable=False)
# Relationships
template = relationship(
"DocumentTemplate",
back_populates="versions",
foreign_keys=[template_id],
primaryjoin="DocumentTemplateVersion.template_id==DocumentTemplate.id",
)
class TemplateKeyword(BaseModel):
"""
Keyword/tag assigned to a DocumentTemplate.
"""
__tablename__ = "template_keywords"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
template_id = Column(Integer, ForeignKey("document_templates.id"), nullable=False, index=True)
keyword = Column(String(100), nullable=False, index=True)
__table_args__ = (
UniqueConstraint("template_id", "keyword", name="uq_template_keyword"),
)
template = relationship(
"DocumentTemplate",
back_populates="keywords",
foreign_keys=[template_id],
primaryjoin="TemplateKeyword.template_id==DocumentTemplate.id",
)

View File

@@ -24,6 +24,7 @@ class User(BaseModel):
# Authorization
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
is_approver = Column(Boolean, default=False)
# User Preferences
theme_preference = Column(String(10), default='light') # 'light', 'dark'

View File

@@ -0,0 +1,213 @@
"""
Notification service with pluggable adapters (email, webhook).
Sends best-effort, non-blocking notifications for domain events such as
QDRO status transitions. Failures are logged and never raise.
"""
from __future__ import annotations
import json
import hmac
import hashlib
import smtplib
from email.message import EmailMessage
from typing import Any, Dict, Iterable, List, Optional
import httpx
from app.config import settings
from sqlalchemy.orm import Session
from app.models.lookups import SystemSetup
class NotificationAdapter:
def send(self, event_type: str, payload: Dict[str, Any]) -> None:
raise NotImplementedError
class EmailAdapter(NotificationAdapter):
def __init__(
self,
*,
smtp_host: str,
smtp_port: int = 587,
username: Optional[str] = None,
password: Optional[str] = None,
starttls: bool = True,
send_from: str = "no-reply@localhost",
default_recipients: Optional[List[str]] = None,
) -> None:
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.starttls = starttls
self.send_from = send_from
self.default_recipients = default_recipients or []
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
override = bool(payload.get("__notify_override"))
recipients: List[str] = [] if override else list(self.default_recipients)
# Allow payload to specify recipients override
extra_to = payload.get("__notify_to")
if isinstance(extra_to, str):
recipients.extend([addr.strip() for addr in extra_to.split(",") if addr.strip()])
elif isinstance(extra_to, list):
recipients.extend([str(x).strip() for x in extra_to if str(x).strip()])
if not recipients:
return
subject = f"{event_type}"
body = json.dumps(payload, default=str, indent=2)
msg = EmailMessage()
msg["From"] = self.send_from
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp:
if self.starttls:
try:
smtp.starttls()
except Exception:
# Continue without TLS if not supported
pass
if self.username and self.password:
smtp.login(self.username, self.password)
smtp.send_message(msg)
class WebhookAdapter(NotificationAdapter):
def __init__(self, *, url: str, secret: Optional[str] = None, timeout: float = 5.0) -> None:
self.url = url
self.secret = secret
self.timeout = timeout
def _signature(self, body: bytes) -> Optional[str]:
if not self.secret:
return None
sig = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return f"sha256={sig}"
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
body = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
headers = {"Content-Type": "application/json"}
sig = self._signature(body)
if sig:
headers["X-Signature"] = sig
try:
with httpx.Client(timeout=self.timeout) as client:
client.post(self.url, content=body, headers=headers)
except Exception:
# Swallow errors by design
pass
class NotificationService:
def __init__(self, adapters: Iterable[NotificationAdapter]) -> None:
self.adapters = list(adapters)
def emit(self, event_type: str, payload: Dict[str, Any]) -> None:
for adapter in self.adapters:
try:
# If a per-event webhook override is present and this is a default webhook adapter
# and override flag is set, skip default webhook send
if isinstance(adapter, WebhookAdapter) and payload.get("__webhook_url") and payload.get("__webhook_override"):
continue
adapter.send(event_type, payload)
except Exception:
# Never block or raise from notification adapters
continue
# If explicit webhook override is provided, send a one-off webhook request
if payload.get("__webhook_url"):
try:
WebhookAdapter(
url=str(payload.get("__webhook_url")),
secret=str(payload.get("__webhook_secret")) if payload.get("__webhook_secret") else None,
).send(event_type, payload)
except Exception:
pass
def build_default_notification_service() -> NotificationService:
if not settings.notifications_enabled:
return NotificationService([])
adapters: List[NotificationAdapter] = []
# Email adapter if SMTP host is configured
if settings.smtp_host:
default_to: List[str] = []
if settings.qdro_notify_email_to:
default_to = [addr.strip() for addr in settings.qdro_notify_email_to.split(",") if addr.strip()]
email_adapter = EmailAdapter(
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
starttls=settings.smtp_starttls,
send_from=settings.notification_email_from,
default_recipients=default_to,
)
adapters.append(email_adapter)
# Webhook adapter if URL is configured
if settings.qdro_notify_webhook_url:
adapters.append(
WebhookAdapter(
url=settings.qdro_notify_webhook_url,
secret=settings.qdro_notify_webhook_secret,
)
)
return NotificationService(adapters)
# Singleton for app code to import
notification_service = build_default_notification_service()
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 resolve_qdro_routes(
db: Session,
*,
file_no: Optional[str],
plan_id: Optional[str],
) -> Dict[str, Optional[Any]]:
"""
Resolve per-file or per-plan routing from SystemSetup.
Precedence: file-specific overrides win over plan-specific, which win over defaults.
Returns a dict with keys: email_to (comma-separated string), webhook_url, webhook_secret.
"""
email_to: Optional[str] = None
webhook_url: Optional[str] = None
webhook_secret: Optional[str] = None
# File overrides
if file_no:
email_to = _get_setting(db, f"notifications.qdro.email.to.file.{file_no}") or email_to
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.file.{file_no}") or webhook_url
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}") or webhook_secret
# Plan overrides (only if not set by file)
if plan_id:
if email_to is None:
email_to = _get_setting(db, f"notifications.qdro.email.to.plan.{plan_id}") or email_to
if webhook_url is None:
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.plan.{plan_id}") or webhook_url
if webhook_secret is None:
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.plan.{plan_id}") or webhook_secret
return {
"email_to": email_to,
"webhook_url": webhook_url,
"webhook_secret": webhook_secret,
}

86
app/services/storage.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Storage abstraction for templates/documents.
MVP: Local filesystem implementation; S3-compatible interface ready.
"""
from __future__ import annotations
import os
import uuid
from typing import Optional
from app.config import settings
class StorageAdapter:
"""Abstract storage adapter."""
def save_bytes(self, *, content: bytes, filename_hint: str, subdir: Optional[str] = None, content_type: Optional[str] = None) -> str:
raise NotImplementedError
def open_bytes(self, storage_path: str) -> bytes:
raise NotImplementedError
def delete(self, storage_path: str) -> bool:
raise NotImplementedError
def exists(self, storage_path: str) -> bool:
raise NotImplementedError
def public_url(self, storage_path: str) -> Optional[str]:
return None
class LocalStorageAdapter(StorageAdapter):
"""Store bytes under settings.upload_dir using relative storage_path."""
def __init__(self, base_dir: Optional[str] = None) -> None:
self.base_dir = os.path.abspath(base_dir or settings.upload_dir)
def _ensure_dir(self, directory: str) -> None:
os.makedirs(directory, exist_ok=True)
def save_bytes(self, *, content: bytes, filename_hint: str, subdir: Optional[str] = None, content_type: Optional[str] = None) -> str:
safe_name = filename_hint.replace("/", "_").replace("\\", "_")
if not os.path.splitext(safe_name)[1]:
# Ensure a default extension when missing
safe_name = f"{safe_name}.bin"
unique = uuid.uuid4().hex
directory = os.path.join(self.base_dir, subdir) if subdir else self.base_dir
self._ensure_dir(directory)
final_name = f"{unique}_{safe_name}"
abs_path = os.path.join(directory, final_name)
with open(abs_path, "wb") as f:
f.write(content)
# Return storage path relative to base_dir for portability
rel_path = os.path.relpath(abs_path, self.base_dir)
return rel_path
def open_bytes(self, storage_path: str) -> bytes:
abs_path = os.path.join(self.base_dir, storage_path)
with open(abs_path, "rb") as f:
return f.read()
def delete(self, storage_path: str) -> bool:
abs_path = os.path.join(self.base_dir, storage_path)
try:
os.remove(abs_path)
return True
except FileNotFoundError:
return False
def exists(self, storage_path: str) -> bool:
abs_path = os.path.join(self.base_dir, storage_path)
return os.path.exists(abs_path)
def public_url(self, storage_path: str) -> Optional[str]:
# Uploads are mounted at /uploads in FastAPI main
# Map base_dir to /uploads; when base_dir is settings.upload_dir, this works.
return f"/uploads/{storage_path}".replace("\\", "/")
def get_default_storage() -> StorageAdapter:
# MVP: always local storage
return LocalStorageAdapter()

View File

@@ -0,0 +1,112 @@
"""
Template variable resolution and DOCX preview using docxtpl.
MVP features:
- Resolve variables from explicit context, FormVariable, ReportVariable
- Built-in variables (dates)
- Render DOCX using docxtpl when mime_type is docx; otherwise return bytes as-is
- Return unresolved tokens list
"""
from __future__ import annotations
import io
import re
from datetime import date, datetime
from typing import Any, Dict, List, Tuple
from sqlalchemy.orm import Session
from app.models.additional import FormVariable, ReportVariable
try:
from docxtpl import DocxTemplate
DOCXTPL_AVAILABLE = True
except Exception:
DOCXTPL_AVAILABLE = False
TOKEN_PATTERN = re.compile(r"\{\{\s*([a-zA-Z0-9_\.]+)\s*\}\}")
def extract_tokens_from_bytes(content: bytes) -> List[str]:
# Prefer docxtpl-based extraction for DOCX if available
if DOCXTPL_AVAILABLE:
try:
buf = io.BytesIO(content)
tpl = DocxTemplate(buf)
# jinja2 analysis for undeclared template variables
vars_set = tpl.get_undeclared_template_variables({})
return sorted({str(v) for v in vars_set})
except Exception:
pass
# Fallback: naive regex over decoded text
try:
text = content.decode("utf-8", errors="ignore")
except Exception:
text = ""
return sorted({m.group(1) for m in TOKEN_PATTERN.finditer(text)})
def build_context(payload_context: Dict[str, Any]) -> Dict[str, Any]:
# Built-ins
today = date.today()
builtins = {
"TODAY": today.strftime("%B %d, %Y"),
"TODAY_ISO": today.isoformat(),
"NOW": datetime.utcnow().isoformat() + "Z",
}
merged = {**builtins}
# Normalize keys to support both FOO and foo
for k, v in payload_context.items():
merged[k] = v
if isinstance(k, str):
merged.setdefault(k.upper(), v)
return merged
def _safe_lookup_variable(db: Session, identifier: str) -> Any:
# 1) FormVariable
fv = db.query(FormVariable).filter(FormVariable.identifier == identifier, FormVariable.active == 1).first()
if fv:
# MVP: use static response if present; otherwise treat as unresolved
if fv.response is not None:
return fv.response
return None
# 2) ReportVariable
rv = db.query(ReportVariable).filter(ReportVariable.identifier == identifier, ReportVariable.active == 1).first()
if rv:
# MVP: no evaluation yet; unresolved
return None
return None
def resolve_tokens(db: Session, tokens: List[str], context: Dict[str, Any]) -> Tuple[Dict[str, Any], List[str]]:
resolved: Dict[str, Any] = {}
unresolved: List[str] = []
for tok in tokens:
# Order: payload context (case-insensitive via upper) -> FormVariable -> ReportVariable
value = context.get(tok)
if value is None:
value = context.get(tok.upper())
if value is None:
value = _safe_lookup_variable(db, tok)
if value is None:
unresolved.append(tok)
else:
resolved[tok] = value
return resolved, unresolved
def render_docx(docx_bytes: bytes, context: Dict[str, Any]) -> bytes:
if not DOCXTPL_AVAILABLE:
# Return original bytes if docxtpl is not installed
return docx_bytes
# Write to BytesIO for docxtpl
in_buffer = io.BytesIO(docx_bytes)
tpl = DocxTemplate(in_buffer)
tpl.render(context)
out_buffer = io.BytesIO()
tpl.save(out_buffer)
return out_buffer.getvalue()

View File

@@ -12,37 +12,37 @@ Based on the comprehensive analysis of the legacy Paradox system, this document
**Legacy Feature**: Sophisticated legal document generation with WordPerfect integration
**Current Status**: ❌ Not implemented
**Current Status**: ✅ **COMPLETED**
**Required Components**:
#### 1.1 Document Template Management
- [ ] Create `DocumentTemplate` model
- [x] Create `DocumentTemplate` model
- Template name, description, file path
- Category/area of law classification
- Status (active/inactive)
- Version tracking
- [ ] Template upload and storage system
- [ ] Template preview capabilities
- [ ] Template search and filtering
- [x] Template upload and storage system
- [x] Template preview capabilities
- [x] Template search and filtering
#### 1.2 Template Keyword/Index System
- [ ] Create `TemplateKeyword` model for searchable tags
- [ ] Keyword management interface
- [ ] Advanced template search by keywords
- [ ] Template categorization system
- [x] Create `TemplateKeyword` model for searchable tags
- [x] Keyword management interface
- [x] Advanced template search by keywords
- [x] Template categorization system
#### 1.3 Data Merge Engine
- [ ] Variable extraction from current data context
- [ ] Merge field mapping system
- [ ] Template variable substitution engine
- [ ] Support for multiple output formats (PDF, DOCX)
- [x] Variable extraction from current data context
- [x] Merge field mapping system
- [x] Template variable substitution engine
- [x] Support for multiple output formats (PDF, DOCX)
#### 1.4 Form Selection Interface
- [ ] Multi-template selection UI
- [ ] Template preview and description display
- [ ] Batch document generation
- [ ] Generated document management
- [x] Multi-template selection UI
- [x] Template preview and description display
- [ ] Batch document generation (planned for future iteration)
- [ ] Generated document management (planned for future iteration)
**API Endpoints Needed**:
```
@@ -53,6 +53,73 @@ GET /api/templates/{id}/preview
POST /api/documents/generate-batch
```
#### 1.5 Storage and Upload (DOCX/PDF)
- [x] Accept `.docx` (mergeable) and `.pdf` (static/stampable) uploads
- [x] Store originals in object storage (S3/MinIO) with server-side encryption
- [x] Persist metadata: filename, MIME type, byte size, checksum (SHA-256), uploader, created_at
- [ ] Virus scan and file-type sniffing (MIME > extension) (security enhancement for future)
- [x] Max size and extension allowlist validation
- [x] Optional tenant-aware storage prefixing (by environment/firm)
#### 1.6 Template Versioning
- [x] Create `DocumentTemplate` and `DocumentTemplateVersion` models
- `DocumentTemplate`: name, description, category, active, current_version_id
- `DocumentTemplateVersion`: template_id, semantic version, storage_path, mime_type, checksum, changelog, created_by, created_at, is_approved
- [x] Upload new versions without breaking existing merges
- [x] Ability to pin a version for a batch/job
- [x] Approval workflow for promoting a draft version to current
#### 1.7 Merge Engine (uses `FormVariable`/`ReportVariable`)
- [x] Variable syntax: `{{ variable_identifier }}` inside `.docx` templates
- [x] Resolution order (short-circuit on first hit):
1) Explicit context passed by API (e.g., file_no, customer, logged_in_user)
2) `FormVariable` by identifier → resolve via a safe, predefined repository function mapped from `query`
3) `ReportVariable` by identifier → same safe resolution
4) Built-ins/utilities (dates, formatting helpers)
- [x] Missing variable strategies: leave token, empty string, or raise warning (configurable)
- [x] Rendering engine: restricted Jinja2-like context with `docxtpl` for `.docx`
- [ ] Output formats: `.docx` (native) and `.pdf` (convert via headless LibreOffice) (PDF conversion for future iteration)
- [x] Robust error reporting: unresolved variables list, stack traces (server logs), user-friendly message
- [x] Auditing: record variables resolved and their sources (context vs `FormVariable`/`ReportVariable`)
#### 1.8 Batch Generation
- [ ] Async queue jobs for batch merges (Celery/RQ) with progress tracking (future iteration)
- [ ] Idempotency keys to avoid duplicate batches (future iteration)
- [ ] Per-item success/failure reporting; partial retry support (future iteration)
- [ ] Output bundling: store each generated document; optional ZIP download of the set (future iteration)
- [ ] Throttling and concurrency limits (future iteration)
- [ ] Audit trail: who initiated, when, template/version used, filters applied (future iteration)
#### 1.9 UI for Templates, Keywords, Previews
- [x] Templates list/detail with version history and diff of changelog
- [x] Upload new version UI with changelog and approval checkbox
- [x] Keyword/tag management (create/read/update/delete) and filter by tags/category
- [x] Variable inspector: show `{{ }}` tokens discovered in the template and their resolution source
- [x] Preview with sample data (single-record merge); show unresolved variables warning
- [ ] Batch generation wizard: choose template/version, select scope (files/customers), review count and estimated time, run asynchronously (future iteration)
#### 1.10 Additional API Endpoints
```
✅ GET /api/templates/{id}
✅ PUT /api/templates/{id}
✅ POST /api/templates/{id}/versions # upload new version
✅ GET /api/templates/{id}/versions # list versions
✅ PUT /api/templates/{id}/versions/{vid} # approve/pin version
✅ GET /api/templates/{id}/variables # introspect template tokens
✅ POST /api/templates/{id}/preview # preview merge with sample/context
⏳ POST /api/documents/jobs # start batch job (future iteration)
⏳ GET /api/documents/jobs/{job_id} # job status/progress (future iteration)
⏳ GET /api/documents/jobs/{job_id}/result # download bundle (ZIP) (future iteration)
✅ POST /api/templates/{id}/keywords # add/remove keywords
```
#### 1.11 Acceptance Criteria
- [x] Upload `.docx` template, add keywords, create v1.0.0, preview succeeds with sample data
- [x] Merge uses `FormVariable`/`ReportVariable` when identifiers match tokens
- [ ] Generate a batch of N files into PDFs; job completes with progress and downloadable ZIP (future iteration)
- [x] Version pinning respected; publishing new version does not alter prior batch outcomes
- [x] All actions logged with user and timestamp; unresolved variables surfaced in UI
### 🔴 2. QDRO (Pension Division) Module
**Legacy Feature**: Specialized module for Qualified Domestic Relations Orders

175
docs/PENSIONS.md Normal file
View File

@@ -0,0 +1,175 @@
# Pensions API Reference (Fields and Sorting)
This document summarizes key fields for pensions-related endpoints and the allowed sort fields for each list.
## Models (key fields)
- Pension
- file_no (string), version (string), plan_id (string), plan_name (string)
- title, first, last, birth, race, sex
- info, valu, accrued, vested_per, start_age
- cola, max_cola, withdrawal, pre_dr, post_dr, tax_rate
- PensionSchedule
- id, file_no, version, vests_on (date), vests_at (number), frequency (string)
- MarriageHistory
- id, file_no, version, spouse_name (string), notes (text)
- married_from (date), married_to (date), married_years (number)
- service_from (date), service_to (date), service_years (number)
- marital_percent (number)
- DeathBenefit
- id, file_no, version
- beneficiary_name (string), benefit_type (string), notes (text)
- lump1, lump2, growth1, growth2, disc1, disc2 (numbers)
- created_at, updated_at (timestamps)
- SeparationAgreement
- id, file_no, version, agreement_date (date), terms (text), notes (text)
## Allowed sort fields
- Schedules (`GET /api/pensions/schedules`)
- id, file_no, version, vests_on, vests_at
- Marriages (`GET /api/pensions/marriages`)
- id, file_no, version, married_from, married_to, marital_percent, service_from, service_to
- Death benefits (`GET /api/pensions/death-benefits`)
- id, file_no, version, lump1, lump2, growth1, growth2, disc1, disc2, created
- Separations (`GET /api/pensions/separations`)
- id, file_no, version, agreement_date
## Notes
- All list endpoints support `skip`, `limit`, `sort_by`, `sort_dir`, and `include_total`.
- Whitelisted fields only are accepted for sorting; unknown fields fall back to defaults.
- Tokenized search semantics: AND across tokens, OR across listed fields. Matching is case-insensitive and trims simple punctuation.
- Sorting behavior: for string columns, sorting is case-insensitive (performed on lowercased values) for stable ordering.
- Basic tokenized search is available per endpoint:
- Schedules: version, frequency
- Marriages: version, spouse_name, notes
- Death benefits: version, beneficiary_name, benefit_type, notes
- Separations: version, terms, notes
## Examples (curl)
Schedules with filters, sorting, include_total:
```bash
curl \
"http://localhost:6920/api/pensions/schedules?file_no=F-1&version=02&vests_at_min=10&vests_at_max=50&sort_by=vests_on&sort_dir=asc&include_total=true"
```
Marriages with tokenized search and sorting:
```bash
curl \
"http://localhost:6920/api/pensions/marriages?file_no=F-1&search=Gamma&sort_by=married_from&sort_dir=desc"
```
Death benefits with numeric ranges and sorting:
```bash
curl \
"http://localhost:6920/api/pensions/death-benefits?file_no=F-1&lump1_min=150&lump1_max=250&growth1_min=1.5&growth1_max=2.5&disc1_min=0.55&disc1_max=0.65&sort_by=lump1&sort_dir=desc"
```
Separations with date range filter and sorting:
```bash
curl \
"http://localhost:6920/api/pensions/separations?file_no=F-1&start=2024-01-01&end=2024-03-31&sort_by=agreement_date&sort_dir=asc"
```
Nested detail with independent paging/sorting per section:
```bash
curl \
"http://localhost:6920/api/pensions/F-1/detail?s_sort_by=vests_on&s_limit=5&m_sort_by=married_from&d_sort_by=lump1&sep_sort_by=agreement_date"
```
## Example responses (shapes)
Schedules as a plain list (default):
```json
[
{
"id": 1,
"file_no": "F-1",
"version": "01",
"vests_on": "2024-01-01",
"vests_at": 10.0,
"frequency": "Monthly"
},
{
"id": 2,
"file_no": "F-1",
"version": "01",
"vests_on": "2024-02-01",
"vests_at": 20.0,
"frequency": "Monthly"
}
]
```
Schedules with include_total=true:
```json
{
"items": [
{
"id": 1,
"file_no": "F-1",
"version": "01",
"vests_on": "2024-01-01",
"vests_at": 10.0,
"frequency": "Monthly"
}
],
"total": 12
}
```
Pension detail with nested lists (trimmed):
```json
{
"pension": {
"id": 123,
"file_no": "F-1",
"version": "01",
"plan_id": "PID1",
"plan_name": "Plan A",
"vested_per": 50.0,
"tax_rate": 25.0
},
"schedules": { "items": [/* ... */], "total": 3 },
"marriages": { "items": [/* ... */], "total": 1 },
"death_benefits": { "items": [/* ... */], "total": 0 },
"separations": { "items": [/* ... */], "total": 2 }
}
```
Sample Pension record (GET /api/pensions/{pension_id}):
```json
{
"id": 123,
"file_no": "F-1",
"version": "01",
"plan_id": "PID1",
"plan_name": "Plan A",
"title": "Mr.",
"first": "John",
"last": "Doe",
"birth": "1970-05-01",
"race": "W",
"sex": "M",
"info": "Notes...",
"valu": 100000.0,
"accrued": 50000.0,
"vested_per": 50.0,
"start_age": 65,
"cola": 2.0,
"max_cola": 3.0,
"withdrawal": "ANNUITY",
"pre_dr": 4.0,
"post_dr": 3.0,
"tax_rate": 25.0
}
```

View File

@@ -24,6 +24,8 @@ email-validator==2.2.0
# Templates & Static Files
jinja2==3.1.4
aiofiles==24.1.0
docxtpl==0.16.7
python-docx==1.1.2
# Testing
pytest==8.3.4

View File

@@ -141,6 +141,10 @@
<i class="fa-solid fa-print"></i>
<span>Printers</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="qdro-notifications-tab" data-tab-target="#qdro-notifications" type="button" role="tab">
<i class="fa-solid fa-bell"></i>
<span>QDRO Notifications</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="issues-tab" data-tab-target="#issues" type="button" role="tab">
<i class="fa-solid fa-bug"></i>
<span>Issues</span>
@@ -618,6 +622,48 @@
</div>
<!-- QDRO Notifications Tab -->
<div id="qdro-notifications" role="tabpanel" class="hidden">
<div class="grid grid-cols-1 gap-6">
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow">
<div class="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h5 class="m-0 font-semibold"><i class="fa-solid fa-bell"></i> QDRO Notification Routes</h5>
<div class="flex items-center gap-2">
<select id="qdro-route-scope-filter" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="">All scopes</option>
<option value="file">file</option>
<option value="plan">plan</option>
</select>
<button type="button" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded text-sm" onclick="showCreateNotificationRouteModal()">
<i class="fas fa-plus"></i> Add Route
</button>
</div>
</div>
<div class="p-4">
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table class="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead class="bg-primary-600 text-white">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Scope</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Identifier</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Email To</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Webhook URL</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="qdro-routes-table-body" class="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
<tr>
<td colspan="5" class="text-center px-4 py-4 text-neutral-500">Loading routes...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Backup Tab -->
<div id="backup" role="tabpanel" class="hidden">
<div class="flex flex-wrap -mx-4">
@@ -1000,6 +1046,57 @@
</div>
</div>
<!-- QDRO Notification Route Modal -->
<div id="qdroRouteModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 p-4">
<div class="mx-auto w-full max-w-xl">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl border border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="font-semibold" id="qdroRouteModalTitle">Add Route</h5>
<button type="button" class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" onclick="closeModal('qdroRouteModal')">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-4 py-4">
<form id="qdroRouteForm">
<input type="hidden" id="qdroRouteMode" value="create">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">Scope *</label>
<select id="routeScope" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" required>
<option value="">Select...</option>
<option value="file">file</option>
<option value="plan">plan</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Identifier *</label>
<input id="routeIdentifier" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="file_no or plan_id" required>
</div>
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Email To</label>
<input id="routeEmailTo" type="email" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="name@example.com">
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Webhook URL</label>
<input id="routeWebhookUrl" type="url" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="https://...">
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Webhook Secret</label>
<input id="routeWebhookSecret" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Optional (leave blank to keep existing)">
</div>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">At least one of Email or Webhook URL must be provided. Secret is only updated if a new value is entered.</p>
</form>
</div>
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-700">
<button type="button" class="px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded hover:bg-neutral-50 dark:hover:bg-neutral-700" onclick="closeModal('qdroRouteModal')">Cancel</button>
<button type="button" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded" onclick="saveNotificationRoute()">Save</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 p-4">
<div class="mx-auto w-full max-w-xl">
@@ -1173,9 +1270,165 @@ function onTabShown(tabName) {
loadSettings();
} else if (tabName === 'printers') {
loadPrinters();
} else if (tabName === 'qdro-notifications') {
loadQDRONotificationRoutes();
}
}
// QDRO Notification Routes
async function loadQDRONotificationRoutes() {
try {
const scopeFilter = document.getElementById('qdro-route-scope-filter');
const selectedScope = scopeFilter ? scopeFilter.value : '';
const url = selectedScope ? `/api/admin/qdro/notification-routes?scope=${encodeURIComponent(selectedScope)}` : '/api/admin/qdro/notification-routes';
const response = await window.http.wrappedFetch(url);
const data = await response.json();
renderQDRORoutesTable(data.items || []);
} catch (error) {
console.error('Failed to load QDRO routes:', error);
const tbody = document.getElementById('qdro-routes-table-body');
if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger-600 dark:text-danger-400">Failed to load routes</td></tr>';
showAlert('Failed to load QDRO notification routes', 'error');
}
}
function renderQDRORoutesTable(items) {
const tbody = document.getElementById('qdro-routes-table-body');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-neutral-500">No routes found</td></tr>';
return;
}
tbody.innerHTML = items.map(route => `
<tr>
<td class="px-4 py-2"><code>${escapeHtml(route.scope)}</code></td>
<td class="px-4 py-2"><code>${escapeHtml(route.identifier)}</code></td>
<td class="px-4 py-2">${route.email_to ? escapeHtml(route.email_to) : '-'}</td>
<td class="px-4 py-2">${route.webhook_url ? `<a href="${escapeAttr(route.webhook_url)}" target="_blank" class="text-primary-600 hover:underline">link</a>` : '-'}</td>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<button class="px-2 py-1 border border-primary-600 text-primary-700 dark:text-primary-200 rounded text-xs hover:bg-primary-50 dark:hover:bg-primary-900/20" onclick="editNotificationRoute('${escapeJs(route.scope)}','${escapeJs(route.identifier)}')" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="px-2 py-1 border border-danger-600 text-danger-700 dark:text-danger-200 rounded text-xs hover:bg-danger-50 dark:hover:bg-danger-900/20" onclick="deleteNotificationRoute('${escapeJs(route.scope)}','${escapeJs(route.identifier)}')" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
}
function showCreateNotificationRouteModal() {
document.getElementById('qdroRouteModalTitle').textContent = 'Add Route';
document.getElementById('qdroRouteMode').value = 'create';
document.getElementById('routeScope').disabled = false;
document.getElementById('routeIdentifier').readOnly = false;
document.getElementById('qdroRouteForm').reset();
openModal('qdroRouteModal');
}
function editNotificationRoute(scope, identifier) {
document.getElementById('qdroRouteModalTitle').textContent = 'Edit Route';
document.getElementById('qdroRouteMode').value = 'edit';
document.getElementById('routeScope').value = scope;
document.getElementById('routeScope').disabled = true;
document.getElementById('routeIdentifier').value = identifier;
document.getElementById('routeIdentifier').readOnly = true;
// Pre-fill from the current row in table
const row = Array.from(document.querySelectorAll('#qdro-routes-table-body tr')).find(tr => {
const tds = tr.querySelectorAll('td');
return tds.length >= 2 && tds[0].textContent.trim() === scope && tds[1].textContent.trim() === identifier;
});
if (row) {
const tds = row.querySelectorAll('td');
const email = tds[2].textContent.trim();
const link = tds[3].querySelector('a');
document.getElementById('routeEmailTo').value = email === '-' ? '' : email;
document.getElementById('routeWebhookUrl').value = link ? link.getAttribute('href') : '';
document.getElementById('routeWebhookSecret').value = '';
}
openModal('qdroRouteModal');
}
async function saveNotificationRoute() {
// Client-side validation
const scope = document.getElementById('routeScope').value.trim();
const identifier = document.getElementById('routeIdentifier').value.trim();
const email_to = document.getElementById('routeEmailTo').value.trim();
const webhook_url = document.getElementById('routeWebhookUrl').value.trim();
const webhook_secret = document.getElementById('routeWebhookSecret').value.trim();
if (!scope) {
showAlert('Scope is required', 'error');
return;
}
if (!identifier) {
showAlert('Identifier is required', 'error');
return;
}
if (!email_to && !webhook_url) {
showAlert('Provide at least Email or Webhook URL', 'error');
return;
}
if (email_to && !/^\S+@\S+\.\S+$/.test(email_to)) {
showAlert('Invalid email address', 'error');
return;
}
if (webhook_url && !/^https?:\/\//i.test(webhook_url)) {
showAlert('Webhook URL must start with http(s)://', 'error');
return;
}
try {
const response = await window.http.wrappedFetch('/api/admin/qdro/notification-routes', {
method: 'POST',
body: JSON.stringify({ scope, identifier, email_to: email_to || null, webhook_url: webhook_url || null, webhook_secret: webhook_secret || null })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showAlert(err.detail || 'Failed to save route', 'error');
return;
}
closeModal('qdroRouteModal');
showAlert('Route saved', 'success');
loadQDRONotificationRoutes();
} catch (error) {
console.error('Failed to save route:', error);
showAlert('Failed to save route', 'error');
}
}
async function deleteNotificationRoute(scope, identifier) {
if (!confirm(`Delete route for ${scope}:${identifier}?`)) return;
try {
const response = await window.http.wrappedFetch(`/api/admin/qdro/notification-routes/${encodeURIComponent(scope)}/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showAlert(err.detail || 'Failed to delete route', 'error');
return;
}
showAlert('Route deleted', 'success');
loadQDRONotificationRoutes();
} catch (error) {
console.error('Failed to delete route:', error);
showAlert('Failed to delete route', 'error');
}
}
// Basic HTML escaping helpers
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }
function escapeJs(str) { return String(str).replace(/['"\\]/g, '\\$&'); }
// Initialize admin dashboard
document.addEventListener('DOMContentLoaded', function() {
// Check admin permissions first
@@ -1195,6 +1448,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Tabs setup
initializeTabs();
// QDRO routes filter change handler
const qdroScopeFilter = document.getElementById('qdro-route-scope-filter');
if (qdroScopeFilter) {
qdroScopeFilter.addEventListener('change', () => {
loadQDRONotificationRoutes();
});
}
// Auto-refresh every 5 minutes
setInterval(loadSystemHealth, 300000);

View File

@@ -20,6 +20,7 @@ class _User:
self.is_active = True
self.first_name = "Test"
self.last_name = "User"
self.is_approver = is_admin
@pytest.fixture()
@@ -168,3 +169,79 @@ def test_printer_setup_crud(client_admin: TestClient):
assert "TestPrinter" not in names
def test_qdro_notification_routes_admin_crud(client_admin: TestClient):
# Initially list should succeed
resp = client_admin.get("/api/admin/qdro/notification-routes")
assert resp.status_code == 200
assert "items" in resp.json()
# Create a per-file route
file_no = "ROUTE-123"
payload = {
"scope": "file",
"identifier": file_no,
"email_to": "a@example.com,b@example.com",
"webhook_url": "https://hooks.example.com/qdro",
"webhook_secret": "sekret",
}
resp = client_admin.post("/api/admin/qdro/notification-routes", json=payload)
assert resp.status_code == 200, resp.text
# Verify appears in list
resp = client_admin.get("/api/admin/qdro/notification-routes?scope=file")
assert resp.status_code == 200
items = resp.json().get("items")
assert any(it["identifier"] == file_no and it["email_to"] for it in items)
# Delete route
resp = client_admin.delete(f"/api/admin/qdro/notification-routes/file/{file_no}")
assert resp.status_code == 200
# Verify gone
resp = client_admin.get("/api/admin/qdro/notification-routes?scope=file")
assert resp.status_code == 200
items = resp.json().get("items")
assert not any(it["identifier"] == file_no for it in items)
def test_approver_toggle_admin_only(client_admin: TestClient):
# Create a user
uname = f"u_{uuid.uuid4().hex[:6]}"
resp = client_admin.post(
"/api/admin/users",
json={
"username": uname,
"email": f"{uname}@example.com",
"password": "secret123",
"first_name": "A",
"last_name": "B",
"is_admin": False,
"is_active": True,
"is_approver": False,
},
)
assert resp.status_code == 200, resp.text
user_id = resp.json()["id"]
# Toggle approver on
resp = client_admin.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": True})
assert resp.status_code == 200, resp.text
assert resp.json()["is_approver"] is True
# Toggle approver off
resp = client_admin.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": False})
assert resp.status_code == 200, resp.text
assert resp.json()["is_approver"] is False
# Non-admin should be forbidden
app.dependency_overrides[get_current_user] = lambda: _User(False)
# Ensure admin override is not present so permission is enforced
prev_admin_override = app.dependency_overrides.pop(get_admin_user, None)
try:
c = TestClient(app)
resp = c.post(f"/api/admin/users/{user_id}/approver", json={"is_approver": True})
assert_http_error(resp, 403, "Not enough permissions")
finally:
if prev_admin_override is not None:
app.dependency_overrides[get_admin_user] = prev_admin_override
app.dependency_overrides.pop(get_current_user, None)

View File

@@ -0,0 +1,171 @@
import os
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars before importing app
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
import app.api.qdros as qdros_module # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from app.database.base import get_db # noqa: E402
from app.models.lookups import SystemSetup # noqa: E402
from tests.test_qdros_api import _create_customer_and_file # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = True
self.is_approver = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
class DummyNotification:
def __init__(self):
self.events = []
def emit(self, event_type: str, payload: dict):
self.events.append((event_type, payload))
def test_qdro_transition_emits_notification_when_notify_true(client: TestClient, monkeypatch):
dummy = DummyNotification()
# Patch the module-level service reference used by endpoints
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Arrange: create file and qdro
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# Act: submit for approval with notify=True
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send to approver", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
# Assert: one event captured with expected shape
assert dummy.events, "Expected a notification event to be emitted"
event_type, payload = dummy.events[-1]
assert event_type == "QDRO_STATUS_CHANGED"
assert payload.get("qdro_id") == qid
assert payload.get("file_no") == file_no
assert payload.get("from") == "DRAFT"
assert payload.get("to") == "APPROVAL_PENDING"
def test_qdro_transition_no_notification_when_notify_false(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# notify omitted/false
resp = client.post(f"/api/qdros/{qid}/submit-for-approval", json={"reason": "send"})
assert resp.status_code == 200, resp.text
assert dummy.events == []
def _upsert_system_setting(db: Session, key: str, value: str):
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
if row:
row.setting_value = value
else:
row = SystemSetup(setting_key=key, setting_value=value)
db.add(row)
db.commit()
def test_per_file_routing_overrides_email_and_webhook(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Create file and qdro
_, file_no = _create_customer_and_file(client)
# Get DB session from dependency directly
db_gen = get_db()
db = next(db_gen)
# Configure per-file routes
_upsert_system_setting(db, f"notifications.qdro.email.to.file.{file_no}", "lawyer@example.com, clerk@example.com")
_upsert_system_setting(db, f"notifications.qdro.webhook.url.file.{file_no}", "https://hooks.example.com/qdro-test")
_upsert_system_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}", "s3cr3t")
resp = client.post("/api/qdros", json={"file_no": file_no, "plan_id": "PL-ABC"})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
# Last event should include override markers
assert dummy.events, "Expected a notification"
_, payload = dummy.events[-1]
assert payload.get("__notify_override") is True
assert "lawyer@example.com" in str(payload.get("__notify_to"))
assert payload.get("__webhook_override") is True
assert payload.get("__webhook_url") == "https://hooks.example.com/qdro-test"
assert payload.get("__webhook_secret") == "s3cr3t"
# Close session
try:
db_gen.close()
except Exception:
pass
def test_plan_routing_applies_when_no_file_override(client: TestClient, monkeypatch):
dummy = DummyNotification()
monkeypatch.setattr(qdros_module, "notification_service", dummy, raising=False)
# Create file + qdro with plan
_, file_no = _create_customer_and_file(client)
db_gen = get_db()
db = next(db_gen)
plan_id = "PL-RTE"
_upsert_system_setting(db, f"notifications.qdro.email.to.plan.{plan_id}", "plan@example.com")
resp = client.post("/api/qdros", json={"file_no": file_no, "plan_id": plan_id})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
resp = client.post(
f"/api/qdros/{qid}/submit-for-approval",
json={"reason": "send", "notify": True, "effective_date": date.today().isoformat()},
)
assert resp.status_code == 200, resp.text
_, payload = dummy.events[-1]
assert payload.get("__notify_override") is True
assert payload.get("__notify_to") == "plan@example.com"
try:
db_gen.close()
except Exception:
pass

257
tests/test_qdros_api.py Normal file
View File

@@ -0,0 +1,257 @@
import os
import io
import uuid
from datetime import date
import pytest
from fastapi.testclient import TestClient
# Ensure required env vars before importing app
os.environ.setdefault("SECRET_KEY", "x" * 32)
os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite")
from app.main import app # noqa: E402
from app.auth.security import get_current_user # noqa: E402
from tests.helpers import assert_http_error, assert_validation_error # noqa: E402
class _User:
def __init__(self):
self.id = 1
self.username = "tester"
self.is_admin = True
self.is_active = True
self.is_approver = True
@pytest.fixture()
def client():
app.dependency_overrides[get_current_user] = lambda: _User()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_current_user, None)
def _create_customer_and_file(client: TestClient) -> tuple[str, str]:
cust_id = f"Q-{uuid.uuid4().hex[:8]}"
resp = client.post("/api/customers/", json={"id": cust_id, "last": "QDRO", "email": "q@example.com"})
assert resp.status_code == 200, resp.text
file_no = f"QF-{uuid.uuid4().hex[:6]}"
payload = {
"file_no": file_no,
"id": cust_id,
"regarding": "QDRO matter",
"empl_num": "E01",
"file_type": "CIVIL",
"opened": date.today().isoformat(),
"status": "ACTIVE",
"rate_per_hour": 150.0,
}
resp = client.post("/api/files/", json=payload)
assert resp.status_code == 200, resp.text
return cust_id, file_no
def _dummy_docx_bytes() -> bytes:
try:
from docx import Document
except Exception:
return b"PK\x03\x04"
d = Document()
p = d.add_paragraph()
p.add_run("QDRO for ")
p.add_run("{{CLIENT_FULL}}")
p = d.add_paragraph()
p.add_run("File ")
p.add_run("{{FILE_NO}}")
buf = io.BytesIO()
d.save(buf)
return buf.getvalue()
def test_qdro_crud_and_list_by_file(client: TestClient):
_, file_no = _create_customer_and_file(client)
# 404 when file missing on create
resp = client.post("/api/qdros", json={"file_no": "NOFILE"})
assert_http_error(resp, 404, "File not found")
# Create
create = {
"file_no": file_no,
"version": "01",
"status": "DRAFT",
"case_number": "2025-1234",
"pet": "Alice",
"res": "Bob",
"percent_awarded": "50%",
}
resp = client.post("/api/qdros", json=create)
assert resp.status_code == 200, resp.text
q = resp.json()
qid = q["id"]
assert q["file_no"] == file_no
# List by file
resp = client.get(f"/api/qdros/{file_no}")
assert resp.status_code == 200
assert any(item["id"] == qid for item in resp.json())
# Get by id
resp = client.get(f"/api/qdros/item/{qid}")
assert resp.status_code == 200
assert resp.json()["id"] == qid
# Update
resp = client.put(f"/api/qdros/{qid}", json={"status": "APPROVED"})
assert resp.status_code == 200
assert resp.json()["status"] == "APPROVED"
# Delete
resp = client.delete(f"/api/qdros/{qid}")
assert resp.status_code == 200
# Now 404
resp = client.get(f"/api/qdros/item/{qid}")
assert_http_error(resp, 404, "QDRO not found")
def test_qdro_division_calculation_and_persist_percent(client: TestClient):
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no})
qid = resp.json()["id"]
# percent -> amount
resp = client.post(f"/api/qdros/{qid}/calculate-division", json={"account_balance": 10000.0, "percent": 25.0})
assert resp.status_code == 200, resp.text
assert resp.json()["amount"] == 2500.0
# amount -> percent and save
resp = client.post(
f"/api/qdros/{qid}/calculate-division",
json={"account_balance": 20000.0, "amount": 5000.0, "save_percent_string": True},
)
assert resp.status_code == 200
pct = resp.json()["percent"]
assert round(pct, 2) == 25.0
# Verify saved percent_awarded string
resp = client.get(f"/api/qdros/item/{qid}")
assert resp.status_code == 200
assert "%" in (resp.json().get("percent_awarded") or "")
def test_qdro_versions_and_communications(client: TestClient):
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no, "content": "Initial"})
qid = resp.json()["id"]
# Create version
resp = client.post(f"/api/qdros/{qid}/versions", json={"version_label": "02", "status": "DRAFT"})
assert resp.status_code == 200
ver = resp.json()
assert ver["version_label"] == "02"
# List versions
resp = client.get(f"/api/qdros/{qid}/versions")
assert resp.status_code == 200
assert any(v["id"] == ver["id"] for v in resp.json())
# Communications
comm = {
"channel": "email",
"subject": "Request Info",
"message": "Please provide latest statements",
"contact_name": "Plan Admin",
"status": "sent",
}
resp = client.post(f"/api/qdros/{qid}/communications", json=comm)
assert resp.status_code == 200
comm_id = resp.json()["id"]
resp = client.get(f"/api/qdros/{qid}/communications")
assert resp.status_code == 200
assert any(c["id"] == comm_id for c in resp.json())
def test_plan_info_create_and_list(client: TestClient):
plan_id = f"PL-{uuid.uuid4().hex[:6]}"
payload = {"plan_id": plan_id, "plan_name": "Acme 401k", "plan_type": "401k"}
resp = client.post("/api/plan-info", json=payload)
assert resp.status_code == 200, resp.text
resp = client.get("/api/plan-info")
assert resp.status_code == 200
ids = {row["plan_id"] for row in resp.json()}
assert plan_id in ids
def test_qdro_document_generation_uses_template_system(client: TestClient):
# Create file + qdro
_, file_no = _create_customer_and_file(client)
resp = client.post("/api/qdros", json={"file_no": file_no, "pet": "Alice", "res": "Bob"})
qid = resp.json()["id"]
# Upload a template through templates API
files = {
"file": (
"qdro.docx",
_dummy_docx_bytes(),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
}
resp = client.post(
"/api/templates/upload",
data={"name": f"QDRO Template {uuid.uuid4().hex[:6]}", "semantic_version": "1.0.0"},
files=files,
)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Generate using our endpoint
resp = client.post(f"/api/qdros/{qid}/generate-document", json={"template_id": tpl_id, "context": {}})
assert resp.status_code == 200, resp.text
body = resp.json()
assert isinstance(body.get("resolved"), dict)
assert isinstance(body.get("unresolved"), list)
assert body.get("output_size", 0) >= 0
def test_qdro_transition_authorization(client: TestClient):
# Override to non-admin, non-approver user
class _LimitedUser:
def __init__(self):
self.id = 2
self.username = "limited"
self.is_admin = False
self.is_active = True
self.is_approver = False
app.dependency_overrides[get_current_user] = lambda: _LimitedUser()
try:
# Set up file and qdro
# Need a client with current override; use existing fixture client indirectly via app
local_client = TestClient(app)
_, file_no = _create_customer_and_file(local_client)
resp = local_client.post("/api/qdros", json={"file_no": file_no})
assert resp.status_code == 200, resp.text
qid = resp.json()["id"]
# DRAFT -> APPROVAL_PENDING allowed
resp = local_client.post(f"/api/qdros/{qid}/submit-for-approval", json={"reason": "send"})
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "APPROVAL_PENDING"
# APPROVAL_PENDING -> APPROVED forbidden for non-approver
resp = local_client.post(f"/api/qdros/{qid}/approve", json={"reason": "ok"})
assert_http_error(resp, 403, "Not enough permissions")
# APPROVAL_PENDING -> FILED forbidden for non-approver
resp = local_client.post(f"/api/qdros/{qid}/file", json={"reason": "file"})
assert_http_error(resp, 403, "Not enough permissions")
# Generic transition to APPROVED forbidden
resp = local_client.post(f"/api/qdros/{qid}/transition", json={"target_status": "APPROVED"})
assert_http_error(resp, 403, "Not enough permissions")
finally:
app.dependency_overrides.pop(get_current_user, None)

View File

@@ -1,5 +1,6 @@
import os
import io
import uuid
from fastapi.testclient import TestClient
import pytest
@@ -393,3 +394,70 @@ def test_templates_categories_listing(client: TestClient):
assert by_cat_all.get("K1", 0) >= 2
assert by_cat_all.get("K2", 0) >= 1
def test_templates_download_current_version(client: TestClient):
# Upload a DOCX template
payload = {
"name": f"Download Letter {uuid.uuid4().hex[:8]}",
"category": "GENERAL",
"description": "Download test",
"semantic_version": "1.0.0",
}
filename = "letter.docx"
content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
data_bytes = _dummy_docx_bytes()
files = {
"file": (filename, data_bytes, content_type),
}
resp = client.post("/api/templates/upload", data=payload, files=files)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Download current approved version
resp = client.get(f"/api/templates/{tpl_id}/download")
assert resp.status_code == 200, resp.text
# Verify headers
assert resp.headers.get("content-type") == content_type
cd = resp.headers.get("content-disposition", "")
assert "attachment;" in cd and filename in cd
# Body should be non-empty and equal to uploaded bytes
assert resp.content == data_bytes
def test_templates_download_specific_version_by_id(client: TestClient):
# Upload initial version
files_v1 = {"file": ("v1.docx", _docx_with_tokens("V1"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
resp = client.post(
"/api/templates/upload",
data={"name": f"MultiVersion {uuid.uuid4().hex[:8]}", "semantic_version": "1.0.0"},
files=files_v1,
)
assert resp.status_code == 200, resp.text
tpl_id = resp.json()["id"]
# Add a second version (do not approve so current stays V1)
v2_bytes = _docx_with_tokens("V2 unique")
files_v2 = {"file": ("v2.docx", v2_bytes, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
resp2 = client.post(
f"/api/templates/{tpl_id}/versions",
data={"semantic_version": "1.1.0", "approve": False},
files=files_v2,
)
assert resp2.status_code == 200, resp2.text
# Find versions to get v2 id
resp_list = client.get(f"/api/templates/{tpl_id}/versions")
assert resp_list.status_code == 200
versions = resp_list.json()
# v2 should be in list; grab the one with semantic_version 1.1.0
v2 = next(v for v in versions if v["semantic_version"] == "1.1.0")
v2_id = v2["id"]
# Download specifically v2
resp_dl = client.get(f"/api/templates/{tpl_id}/download", params={"version_id": v2_id})
assert resp_dl.status_code == 200, resp_dl.text
assert resp_dl.headers.get("content-type") == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
cd2 = resp_dl.headers.get("content-disposition", "")
assert "v2.docx" in cd2
assert resp_dl.content == v2_bytes