""" 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