""" Document Management API endpoints - QDROs, Templates, and General Documents """ from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form, Request from sqlalchemy.orm import Session, joinedload from sqlalchemy import or_, func, and_, desc, asc, text from datetime import date, datetime import os import uuid import shutil from app.database.base import get_db from app.models.qdro import QDRO from app.models.files import File as FileModel from app.models.rolodex import Rolodex from app.models.lookups import FormIndex, FormList, Footer, Employee from app.models.user import User from app.auth.security import get_current_user from app.models.additional import Document from app.core.logging import get_logger from app.services.audit import audit_service router = APIRouter() # Pydantic schemas from pydantic import BaseModel class QDROBase(BaseModel): file_no: str version: str = "01" title: Optional[str] = None content: Optional[str] = None status: str = "DRAFT" created_date: Optional[date] = None approved_date: Optional[date] = None filed_date: Optional[date] = None participant_name: Optional[str] = None spouse_name: Optional[str] = None plan_name: Optional[str] = None plan_administrator: Optional[str] = None notes: Optional[str] = None class QDROCreate(QDROBase): pass class QDROUpdate(BaseModel): version: Optional[str] = None title: Optional[str] = None content: Optional[str] = None status: Optional[str] = None created_date: Optional[date] = None approved_date: Optional[date] = None filed_date: Optional[date] = None participant_name: Optional[str] = None spouse_name: Optional[str] = None plan_name: Optional[str] = None plan_administrator: Optional[str] = None notes: Optional[str] = None class QDROResponse(QDROBase): id: int class Config: from_attributes = True @router.get("/qdros/{file_no}", response_model=List[QDROResponse]) async def get_file_qdros( file_no: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get QDROs for specific file""" qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(QDRO.version).all() return qdros @router.get("/qdros/", response_model=List[QDROResponse]) async def list_qdros( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), status_filter: Optional[str] = Query(None), search: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """List all QDROs with filtering""" query = db.query(QDRO) if status_filter: query = query.filter(QDRO.status == status_filter) if search: query = query.filter( or_( QDRO.file_no.contains(search), QDRO.title.contains(search), QDRO.participant_name.contains(search), QDRO.spouse_name.contains(search), QDRO.plan_name.contains(search) ) ) qdros = query.offset(skip).limit(limit).all() return qdros @router.post("/qdros/", response_model=QDROResponse) async def create_qdro( qdro_data: QDROCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create new QDRO""" qdro = QDRO(**qdro_data.model_dump()) if not qdro.created_date: qdro.created_date = date.today() db.add(qdro) db.commit() db.refresh(qdro) return qdro @router.get("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse) async def get_qdro( file_no: str, qdro_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get specific QDRO""" qdro = db.query(QDRO).filter( QDRO.id == qdro_id, QDRO.file_no == file_no ).first() if not qdro: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="QDRO not found" ) return qdro @router.put("/qdros/{file_no}/{qdro_id}", response_model=QDROResponse) async def update_qdro( file_no: str, qdro_id: int, qdro_data: QDROUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update QDRO""" qdro = db.query(QDRO).filter( QDRO.id == qdro_id, QDRO.file_no == file_no ).first() if not qdro: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="QDRO not found" ) # Update fields for field, value in qdro_data.model_dump(exclude_unset=True).items(): setattr(qdro, field, value) db.commit() db.refresh(qdro) return qdro @router.delete("/qdros/{file_no}/{qdro_id}") async def delete_qdro( file_no: str, qdro_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete QDRO""" qdro = db.query(QDRO).filter( QDRO.id == qdro_id, QDRO.file_no == file_no ).first() if not qdro: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="QDRO not found" ) db.delete(qdro) db.commit() return {"message": "QDRO deleted successfully"} # Enhanced Document Management Endpoints # Template Management Schemas class TemplateBase(BaseModel): """Base template schema""" form_id: str form_name: str category: str = "GENERAL" content: str = "" variables: Optional[Dict[str, str]] = None class TemplateCreate(TemplateBase): pass class TemplateUpdate(BaseModel): form_name: Optional[str] = None category: Optional[str] = None content: Optional[str] = None variables: Optional[Dict[str, str]] = None class TemplateResponse(TemplateBase): active: bool = True created_at: Optional[datetime] = None class Config: from_attributes = True # Document Generation Schema class DocumentGenerateRequest(BaseModel): """Request to generate document from template""" template_id: str file_no: str output_format: str = "PDF" # PDF, DOCX, HTML variables: Optional[Dict[str, Any]] = None class DocumentResponse(BaseModel): """Generated document response""" document_id: str file_name: str file_path: str size: int created_at: datetime # Document Statistics class DocumentStats(BaseModel): """Document system statistics""" total_templates: int total_qdros: int templates_by_category: Dict[str, int] recent_activity: List[Dict[str, Any]] @router.get("/templates/", response_model=List[TemplateResponse]) async def list_templates( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), category: Optional[str] = Query(None), search: Optional[str] = Query(None), active_only: bool = Query(True), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """List available document templates""" query = db.query(FormIndex) if active_only: query = query.filter(FormIndex.active == True) if category: query = query.filter(FormIndex.category == category) if search: query = query.filter( or_( FormIndex.form_name.contains(search), FormIndex.form_id.contains(search) ) ) templates = query.offset(skip).limit(limit).all() # Enhanced response with template content results = [] for template in templates: template_lines = db.query(FormList).filter( FormList.form_id == template.form_id ).order_by(FormList.line_number).all() content = "\n".join([line.content or "" for line in template_lines]) results.append({ "form_id": template.form_id, "form_name": template.form_name, "category": template.category, "content": content, "active": template.active, "created_at": template.created_at, "variables": _extract_variables_from_content(content) }) return results @router.post("/templates/", response_model=TemplateResponse) async def create_template( template_data: TemplateCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create new document template""" # Check if template already exists existing = db.query(FormIndex).filter(FormIndex.form_id == template_data.form_id).first() if existing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Template with this ID already exists" ) # Create form index entry form_index = FormIndex( form_id=template_data.form_id, form_name=template_data.form_name, category=template_data.category, active=True ) db.add(form_index) # Create form content lines content_lines = template_data.content.split('\n') for i, line in enumerate(content_lines, 1): form_line = FormList( form_id=template_data.form_id, line_number=i, content=line ) db.add(form_line) db.commit() db.refresh(form_index) return { "form_id": form_index.form_id, "form_name": form_index.form_name, "category": form_index.category, "content": template_data.content, "active": form_index.active, "created_at": form_index.created_at, "variables": template_data.variables or {} } @router.get("/templates/{template_id}", response_model=TemplateResponse) async def get_template( template_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get specific template with content""" template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found" ) # Get template content template_lines = db.query(FormList).filter( FormList.form_id == template_id ).order_by(FormList.line_number).all() content = "\n".join([line.content or "" for line in template_lines]) return { "form_id": template.form_id, "form_name": template.form_name, "category": template.category, "content": content, "active": template.active, "created_at": template.created_at, "variables": _extract_variables_from_content(content) } @router.put("/templates/{template_id}", response_model=TemplateResponse) async def update_template( template_id: str, template_data: TemplateUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update document template""" template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found" ) # Update form index if template_data.form_name: template.form_name = template_data.form_name if template_data.category: template.category = template_data.category # Update content if provided if template_data.content is not None: # Delete existing content lines db.query(FormList).filter(FormList.form_id == template_id).delete() # Add new content lines content_lines = template_data.content.split('\n') for i, line in enumerate(content_lines, 1): form_line = FormList( form_id=template_id, line_number=i, content=line ) db.add(form_line) db.commit() db.refresh(template) # Get updated content template_lines = db.query(FormList).filter( FormList.form_id == template_id ).order_by(FormList.line_number).all() content = "\n".join([line.content or "" for line in template_lines]) return { "form_id": template.form_id, "form_name": template.form_name, "category": template.category, "content": content, "active": template.active, "created_at": template.created_at, "variables": _extract_variables_from_content(content) } @router.delete("/templates/{template_id}") async def delete_template( template_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete document template""" template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found" ) # Delete content lines db.query(FormList).filter(FormList.form_id == template_id).delete() # Delete template db.delete(template) db.commit() return {"message": "Template deleted successfully"} @router.post("/generate/{template_id}") async def generate_document( template_id: str, request: DocumentGenerateRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Generate document from template""" # Get template template = db.query(FormIndex).filter(FormIndex.form_id == template_id).first() if not template: raise HTTPException(status_code=404, detail="Template not found") # Get file information file_obj = db.query(FileModel).options( joinedload(FileModel.owner) ).filter(FileModel.file_no == request.file_no).first() if not file_obj: raise HTTPException(status_code=404, detail="File not found") # Get template content template_lines = db.query(FormList).filter( FormList.form_id == template_id ).order_by(FormList.line_number).all() template_content = "\n".join([line.content or "" for line in template_lines]) # Prepare merge variables merge_vars = { "FILE_NO": file_obj.file_no, "CLIENT_FIRST": file_obj.owner.first if file_obj.owner else "", "CLIENT_LAST": file_obj.owner.last if file_obj.owner else "", "CLIENT_FULL": f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip() if file_obj.owner else "", "MATTER": file_obj.regarding or "", "OPENED": file_obj.opened.strftime("%B %d, %Y") if file_obj.opened else "", "ATTORNEY": file_obj.empl_num or "", "TODAY": date.today().strftime("%B %d, %Y") } # Add any custom variables from the request if request.variables: merge_vars.update(request.variables) # Perform variable substitution merged_content = _merge_template_variables(template_content, merge_vars) # Generate document file document_id = str(uuid.uuid4()) file_name = f"{template.form_name}_{file_obj.file_no}_{date.today().isoformat()}" if request.output_format.upper() == "PDF": file_path = f"/app/exports/{document_id}.pdf" file_name += ".pdf" # Here you would implement PDF generation # For now, create a simple text file with open(f"/app/exports/{document_id}.txt", "w") as f: f.write(merged_content) file_path = f"/app/exports/{document_id}.txt" elif request.output_format.upper() == "DOCX": file_path = f"/app/exports/{document_id}.docx" file_name += ".docx" # Implement DOCX generation with open(f"/app/exports/{document_id}.txt", "w") as f: f.write(merged_content) file_path = f"/app/exports/{document_id}.txt" else: # HTML file_path = f"/app/exports/{document_id}.html" file_name += ".html" html_content = f"
{merged_content}"
with open(file_path, "w") as f:
f.write(html_content)
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
return {
"document_id": document_id,
"file_name": file_name,
"file_path": file_path,
"size": file_size,
"created_at": datetime.now()
}
@router.get("/categories/")
async def get_template_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available template categories"""
categories = db.query(FormIndex.category).distinct().all()
return [cat[0] for cat in categories if cat[0]]
@router.get("/stats/summary")
async def get_document_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get document system statistics"""
# Template statistics
total_templates = db.query(FormIndex).filter(FormIndex.active == True).count()
total_qdros = db.query(QDRO).count()
# Templates by category
category_stats = db.query(
FormIndex.category,
func.count(FormIndex.form_id)
).filter(FormIndex.active == True).group_by(FormIndex.category).all()
categories_dict = {cat[0] or "Uncategorized": cat[1] for cat in category_stats}
# Recent QDRO activity
recent_qdros = db.query(QDRO).order_by(desc(QDRO.updated_at)).limit(5).all()
recent_activity = [
{
"type": "QDRO",
"file_no": qdro.file_no,
"status": qdro.status,
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None
}
for qdro in recent_qdros
]
return {
"total_templates": total_templates,
"total_qdros": total_qdros,
"templates_by_category": categories_dict,
"recent_activity": recent_activity
}
@router.get("/file/{file_no}/documents")
async def get_file_documents(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all documents associated with a specific file"""
# Get QDROs for this file
qdros = db.query(QDRO).filter(QDRO.file_no == file_no).order_by(desc(QDRO.updated_at)).all()
# Format response
documents = [
{
"id": qdro.id,
"type": "QDRO",
"title": f"QDRO v{qdro.version}",
"status": qdro.status,
"created_date": qdro.created_date.isoformat() if qdro.created_date else None,
"updated_at": qdro.updated_at.isoformat() if qdro.updated_at else None,
"file_no": qdro.file_no
}
for qdro in qdros
]
return {
"file_no": file_no,
"documents": documents,
"total_count": len(documents)
}
def _extract_variables_from_content(content: str) -> Dict[str, str]:
"""Extract variable placeholders from template content"""
import re
variables = {}
# Find variables in format {{VARIABLE_NAME}}
matches = re.findall(r'\{\{([^}]+)\}\}', content)
for match in matches:
var_name = match.strip()
variables[var_name] = f"Placeholder for {var_name}"
# Find variables in format ^VARIABLE
matches = re.findall(r'\^([A-Z_]+)', content)
for match in matches:
variables[match] = f"Placeholder for {match}"
return variables
def _merge_template_variables(content: str, variables: Dict[str, Any]) -> str:
"""Replace template variables with actual values"""
merged = content
# Replace {{VARIABLE}} format
for var_name, value in variables.items():
merged = merged.replace(f"{{{{{var_name}}}}}", str(value or ""))
merged = merged.replace(f"^{var_name}", str(value or ""))
return merged
# --- Client Error Logging (for Documents page) ---
class ClientErrorLog(BaseModel):
"""Payload for client-side error logging"""
message: str
action: Optional[str] = None
stack: Optional[str] = None
url: Optional[str] = None
line: Optional[int] = None
column: Optional[int] = None
user_agent: Optional[str] = None
extra: Optional[Dict[str, Any]] = None
@router.post("/client-error")
async def log_client_error(
payload: ClientErrorLog,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(lambda: None)
):
"""Accept client-side error logs from the Documents page.
This endpoint is lightweight and safe to call; it records the error to the
application logs and best-effort to the audit log without interrupting the UI.
"""
logger = get_logger("client.documents")
client_ip = request.headers.get("x-forwarded-for")
if client_ip:
client_ip = client_ip.split(",")[0].strip()
else:
client_ip = request.client.host if request.client else None
logger.error(
"Client error reported",
action=payload.action,
message=payload.message,
stack=payload.stack,
page="/documents",
url=payload.url or str(request.url),
line=payload.line,
column=payload.column,
user=getattr(current_user, "username", None),
user_id=getattr(current_user, "id", None),
user_agent=payload.user_agent or request.headers.get("user-agent"),
client_ip=client_ip,
extra=payload.extra,
)
# Best-effort audit log; do not raise on failure
try:
audit_service.log_action(
db=db,
action="CLIENT_ERROR",
resource_type="DOCUMENTS",
user=current_user,
resource_id=None,
details={
"action": payload.action,
"message": payload.message,
"url": payload.url or str(request.url),
"line": payload.line,
"column": payload.column,
"extra": payload.extra,
},
request=request,
)
except Exception:
pass
return {"status": "logged"}
@router.post("/upload/{file_no}")
async def upload_document(
file_no: str,
file: UploadFile = File(...),
description: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Upload a document to a file"""
file_obj = db.query(FileModel).filter(FileModel.file_no == file_no).first()
if not file_obj:
raise HTTPException(status_code=404, detail="File not found")
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
allowed_types = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"image/jpeg",
"image/png"
]
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail="Invalid file type")
max_size = 10 * 1024 * 1024 # 10MB
content = await file.read()
if len(content) > max_size:
raise HTTPException(status_code=400, detail="File too large")
upload_dir = f"uploads/{file_no}"
os.makedirs(upload_dir, exist_ok=True)
ext = file.filename.split(".")[-1]
unique_name = f"{uuid.uuid4()}.{ext}"
path = f"{upload_dir}/{unique_name}"
with open(path, "wb") as f:
f.write(content)
doc = Document(
file_no=file_no,
filename=file.filename,
path=path,
description=description,
type=file.content_type,
size=len(content),
uploaded_by=current_user.username
)
db.add(doc)
db.commit()
db.refresh(doc)
return doc
@router.get("/{file_no}/uploaded")
async def list_uploaded_documents(
file_no: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List uploaded documents for a file"""
docs = db.query(Document).filter(Document.file_no == file_no).all()
return docs
@router.delete("/uploaded/{doc_id}")
async def delete_document(
doc_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete an uploaded document"""
doc = db.query(Document).filter(Document.id == doc_id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if os.path.exists(doc.path):
os.remove(doc.path)
db.delete(doc)
db.commit()
return {"message": "Document deleted successfully"}
@router.put("/uploaded/{doc_id}")
async def update_document(
doc_id: int,
description: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update document description"""
doc = db.query(Document).filter(Document.id == doc_id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
doc.description = description
db.commit()
db.refresh(doc)
return doc