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