Files
delphi-database/app/api/documents.py
2025-08-13 18:53:35 -05:00

864 lines
26 KiB
Python

"""
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"""
# Only accept fields that exist on the model and exclude None values
allowed_fields = {c.name for c in QDRO.__table__.columns}
payload = {
k: v
for k, v in qdro_data.model_dump(exclude_unset=True).items()
if v is not None and k in allowed_fields
}
qdro = QDRO(**payload)
# Backfill created_date if model supports it; otherwise rely on created_at
if hasattr(qdro, "created_date") and not getattr(qdro, "created_date"):
setattr(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 present on the model only
allowed_fields = {c.name for c in QDRO.__table__.columns}
for field, value in qdro_data.model_dump(exclude_unset=True).items():
if field in allowed_fields:
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()}"
exports_dir = "/app/exports"
try:
os.makedirs(exports_dir, exist_ok=True)
except Exception:
try:
os.makedirs("exports", exist_ok=True)
exports_dir = "exports"
except Exception:
exports_dir = "."
if request.output_format.upper() == "PDF":
file_path = f"{exports_dir}/{document_id}.pdf"
file_name += ".pdf"
# Here you would implement PDF generation
# For now, create a simple text file
with open(f"{exports_dir}/{document_id}.txt", "w") as f:
f.write(merged_content)
file_path = f"{exports_dir}/{document_id}.txt"
elif request.output_format.upper() == "DOCX":
file_path = f"{exports_dir}/{document_id}.docx"
file_name += ".docx"
# Implement DOCX generation
with open(f"{exports_dir}/{document_id}.txt", "w") as f:
f.write(merged_content)
file_path = f"{exports_dir}/{document_id}.txt"
else: # HTML
file_path = f"{exports_dir}/{document_id}.html"
file_name += ".html"
html_content = f"<html><body><pre>{merged_content}</pre></body></html>"
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()
# Treat zero-byte payloads as no file uploaded to provide a clearer client error
if len(content) == 0:
raise HTTPException(status_code=400, detail="No file uploaded")
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