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}')>"
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()