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,
|
||||
|
||||
Reference in New Issue
Block a user