finishing QDRO section
This commit is contained in:
174
app/api/admin.py
174
app/api/admin.py
@@ -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")
|
||||
|
||||
@@ -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
694
app/api/qdros.py
Normal 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
98
app/models/templates.py
Normal 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",
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
213
app/services/notification.py
Normal file
213
app/services/notification.py
Normal 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
86
app/services/storage.py
Normal 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()
|
||||
|
||||
|
||||
112
app/services/template_merge.py
Normal file
112
app/services/template_merge.py
Normal 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()
|
||||
|
||||
|
||||
@@ -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
175
docs/PENSIONS.md
Normal 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
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
171
tests/test_qdro_notifications.py
Normal file
171
tests/test_qdro_notifications.py
Normal 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
257
tests/test_qdros_api.py
Normal 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user